Get Firefox

Member of The Internet Defense League

stopsoftwarepatents.eu petition banner Últimos cambios
Últimos Cambios
Blog personal: El hilo del laberinto Geocaching

¿Cómo Escribir Programas Seguros?

Última Actualización: 3 de Noviembre de 1.999 - Miércoles

Artículo escrito el 31/Jul/98 y publicado en Linux Actual, número 4

Muchos de nosotros nos enfrentamos día a día a la necesidad de programar procesos críticos, muchas veces accesibles a miles de usuarios presumiblemente hostiles (por ejemplo, un CGI web), y cuyo compromiso puede poner en peligro la integridad o la seguridad de todo el sistema. Procesos necesarios, sin los que no se puede vivir, pero de los que nos gustaría poder prescindir porque su misma existencia supone un riesgo. En casos así, resulta imprescindible el seguir una metodología clara y detallada para intentar hacer al programa lo más robusto, fiable y seguro que sea posible.

En este artículo vamos a intentar dar algunos consejos para la programación segura en C. Al final del texto incluiré también algunas referencias que serán útiles a los programadores de PERL, ya que es un lenguaje que yo apenas utilizo.

Lo primero que hay que decir es que escribir código seguro dista mucho de ser una tarea trivial. Existe abundante bibliografía sobre el tema, pero tristemente comprobamos como, día a día, los programadores cometen (cometemos) los mismos errores una y otra vez. Muchos programadores no son conscientes de los riesgos de seguridad que corren los administradores que instalan sus programas y, en otros casos, sencillamente somos lo bastante humanos como para cometer errores. Lamentablemente un error en estos entornos es algo que nos puede costar muy caro.

Programar de forma segura, aunque no excesivamente complicado, sí resulta tedioso y proclive a fallos. Además de lidiar con los tópicos e inevitables "bugs" en los programas, debemos hacer gala de una sana pero extrema paranoia. No fiarnos de nadie. No fiarnos de nada. No dormir por las noches. Curarnos en salud, por así decirlo, si no nos morimos antes de agotamiento o falta de sueño.

Resulta útil analizar con lupa las especificaciones funcionales del programa. Las clasificaciones siempre son útiles para aclarar ideas:

  1. Condiciones internas al programa

    Son debidas a bugs o presunciones dentro de nuestro propio código. Un ejemplo clásico son los errores de desbordamiento debido a funciones como "strcpy()".

  2. Condiciones externas al programa

    Aquí nos estamos enfrentando a circunstancias exteriores no sujetas a nuestro control: enlaces simbólicos en vez de ficheros regulares, directorios/ficheros públicos, "race conditions", interacciones con otros programas o el sistema operativo, etc.

  3. Ataques remotos

    Los ataques remotos son aquellos que tienen lugar a través de una red, como puede ser Internet. Evidentemente, sólo tendremos que preocuparnos de ellos si el programa que estamos creando es accesible desde el exterior.

  4. Ataques locales

    Los ataques locales cubren aquellos llevados a cabo dentro del entorno del programa, ya sea de forma accidental o debido a un usuario malicioso. Los usuarios locales son siempre mucho más peligrosos que cualquier hacker de internet, ya que su acceso es infinitamente más abierto. Además, un usuario local siempre está a tiempo de desencadenar un ataque remoto con un simple "Telnet" a su misma máquina.

Para que este artículo tenga un tamaño manejable (el tema es lo suficientemente amplio como para escribir un libro) no tengo más remedio que conformarme con publicar poco más que un esquema. Un esquema grande y complejo, eso sí. Espero que sirva para salvar el día a más de uno.

  1. Sólo los privilegios imprescindibles

    Cualquier programa debería ejecutarse exclusivamente con los privilegios imprescindibles. Idealmente el programa debería tener vetado el acceso a todos los recursos del sistema, y el administrador debería poder abrirle el acceso recurso por recurso, tal y como se hace en los grandes "mainframes".

    En este aspecto el comando "chroot" resulta de gran ayuda, ya que posibilita compartimentar hasta cierto punto los recursos dependientes del sistema de ficheros (en Unix son la inmensa mayoría). No obstante, es imprescindible hacer notar que no es la panacea, sino una herramienta más. Hay que tener muchas cosas en cuenta cuando se utiliza, como se indica en los siguientes textos:

    Chroot y Seguridad
    Linux Actual, año 1, número 3
    Páginas 75-76

    http://www.argo.es/~jcea/artic/chroot.htm
    http://www.argo.es/~jcea/artic/chroot2.htm

    El ser sobrio en los privilegios incluye a los programas SUID. Un programa SUID (otras personas les llaman SETUID, cuestión de nomenclatura) es aquel que se ejecuta con los privilegios de su propietario, no con los privilegios del usuario que lo invoca. Por ejemplo, para cambiar la clave de un usuario hace falta poder escribir en "/etc/passwd", lo que sólo puede hacer "root". Evidentemente no queremos que el usuario, con ese acceso, pueda hacer otra cosa que cambiar su clave, ¿verdad?.

  2. Usar procesos SUID/SGID de forma inteligente

    Existen casos, sin embargo, en los que un proceso SUID contribuye a mejorar la seguridad sensiblemente. Por ejemplo, un CGI web suele ejecutarse con los privilegios del propio servidor web (normalmente como "nobody"). No obstante el usuario "nobody" tiene acceso de lectura a los ficheros públicos del servidor, lo que suele incluir el propio "/etc/passwd". Si no estamos usando "shadow" (muchas instalaciones Linux no lo usan) significa que un compromiso en el CGI permite a un atacante obtener una copia del fichero de claves. Si tenemos algún tipo de base de datos para el control de acceso a algunas partes del web, también será accesible por el CGI.

    En un contexto así lo ideal sería ejecutar el servidor Web directamente dentro de un "chroot". Si ello es posible, perfecto, pero no siempre es así. Un servidor Web, en instalaciones pequeñas, suele tener acceso "legítimo" a ficheros repartidos por todo el disco, y proteger eso es misión imposible.

    ¿De qué nos sirve un script SUID aquí?. Un script ejecutado como "nobody" por el servidor web, pero SUID a "root" se ejecuta como "root". Ello permite, entre otras cosas, que el script haga un "chroot" y se restrinja a sí mismo dentro de un directorio. Si lo siguiente que hace el script es un "setgid()" y "setuid()" a "nobody", tendremos un script ejecutándose como "nobody" y restringido dentro de un "chroot" sin necesidad de acotar el propio servidor web dentro de esa celda.

    También se pueden utilizar los procesos SUID para lo contrario de lo normal: en vez de servirse de ellos para incrementar temporalmente nuestros privilegios, usémoslos para reducirlos. Por ejemplo, yo tengo una serie de programas muy útiles, pero que ejecutados como "root" pueden ser francamente peligrosos. En esos casos el programa está como SUID "nobody", asegurándome así el no cometer ningún error irremediable si lo ejecuto como "root".

    El Argobot, por ejemplo (http://www.argo.es/~jcea/irc/argobot.htm), es un programa SUID cuyas tres primeras líneas de código son "chroot()", "setgid()" y "setuid()". El proceso es SUID "root" para poder hacer el "chroot" y, de esta forma, asegurarnos que aunque se comprometa la seguridad del programa no ocurrirá nada grave.

    La regla general, no obstante, es librarse de los privilegios lo antes posible. Un proceso SUID debe cambiar a un usuario inocuo en cuanto pueda. El número de línea de código a ejecutar bajo los privilegios debe ser lo menor posible, y deben estudiarse, repasarse y probarse hasta la saciedad. No hay motivo alguno para no hacerlo así.

    En los casos en los que un programa requiera privilegios excepcionales de vez en cuando, es preferible dividir el código en dos módulos, uno normal y otro SUID, que es el que se llama cuando es necesario y que debe tener una interfaz muy clara y concisa, un código concienzudamente revisado y ser lo más corto posible.

    Puedes comprobar los programas SUID en un árbol del directorio tecleando "find . -perm -4000 -print".

    Un último detalle: nunca utilices scripts shell SUID, ya que existe una "race condition" que permitiría ejecutar cualquier programa como SUID.

  3. Afinar el control de accesos

    Tradicionalmente Unix basa su control de acceso en los nueve famosos y archiconocidos bits "rwx". Sin embargo, muchos Unix modernos (por ejemplo, Solaris) permiten fijar privilegios de acceso usuario por usuario y grupo por grupo, para cada fichero y directorio. Es lo que se llama ACL: Access Control List. Con ello podemos crear un control de acceso para todos los directorios del sistema en el que el usuario "nobody" no tenga permisos de ningún tipo (salvo en los ficheros que sea absolutamente necesario).

    La tecnología ACL es una de las tradicionales ventajas del mundo de los "mainframes", pero poco a poco empieza a importarse al mundo Unix. Su gran ventaja es que permite un ajuste extremadamente fino de los usuarios y grupos que tienen acceso (y qué tipo de acceso) a cada fichero del sistema. Sus mayores desventajas son el hecho de no estar todavía muy difundido, que la mayoría de las herramientas estándar Unix no saben cómo manejarlas, y que en muchas implementaciones suponen una pérdida de rendimiento apreciable.

    Una descripción detallada del ACL constituye un artículo en sí mismo (en un futuro próximo :-)), además de no estar disponible en muchos de los Unix más populares. En todo caso os recomiendo que si teneis de la suerte de tener acceso a un sistema con esas funcionalidades os leais con calma los manuales de "acl()", "facl()", "setfacl" y "getfacl". Seguro que a más de uno se le "caerá la baba".

    El uso combinado de un buen "chroot" en condiciones, siempre que sea posible, como se indica en el primer punto, y la tecnología ACL, posibilitan cerrar a un proceso todos los recursos menos los absolutamente necesarios para su funcionamiento.

    Normalmente los ficheros y directorios con ACL activado se marcan con un signo "más" ("+") en el listado de modos.

    En sistemas sin ACL es relativamente sencillo instalar un "wrapper" que haga "chroot" y cambie el "uid" y "gid" a algo relativamente inocuo. Puede encontrarse una implementación, por ejemplo, en http://www.umr.edu/~cgiwrap/.

  4. Cuidado con los "Buffer Overflow"

    Últimamente muy de moda, los "Buffer Overflow" son una de las vulnerabilidades de seguridad más peligrosas que puede tener un programa ya que, por lo general, permiten la ejecución de código arbitrario en el servidor. Las implicaciones son claras, sobre todo si el demonio corre como "root". El típico error de desbordamiento se produce cuando el programa intenta escribir o copiar en un búffer interno más bytes que el tamaño del búffer, y los bytes que sobran machacan la memoria que lo rodea (típicamente el stack). Lo menos que se puede esperar, en esos casos, es que el servicio muera debido a la corrupción de sus estructuras de datos pero, con un poco de suerte, un atacante puede llegar a ejecutar el código que desee.

    En lenguajes donde existe implícita una comprobación de límites, como puede ser el ADA, el Módula-2/3, Java o el entrañable BASIC, el riesgo de corromper la memoria es mínimo; al menos mientras no nos dediquemos a jugar con punteros. Pero lenguajes como el C, hoy por hoy tan populares en este mundillo, no disponen de esas verificaciones y es el programador, por tanto, quien debe tener los ojos bien abiertos mientras mueve datos de un lado a otro de la memoria.

    Debemos programar defensivamente, por tanto, anticipándonos al atacante. No podemos confiar en conocer la longitud máxima de ninguna estructura que no hayamos definido nosotros mismos, sobre todo si está bajo el control del atacante. Por ejemplo, si nuestro CGI hace algo así:

    sprintf(buf,"Bienvenido, usuario %s\n",remote_hostname);

    y "remote_hostname" es el nombre de la máquina remota, primero deberíamos comprobar su longitud so pena de que el atacante tenga acceso al DNS e intente desbordar "buf".

    Recordad: un poco de paranoia sana no hace mal a nadie. Sólo quita un poco de sueño y nos hace crecer las dioptrías de tanto ver la pantalla, leer documentación técnica y recibir avisos de seguridad.

    En general todas las funciones que escriban en memoria sin una acotación de tamaño son peligrosas y deben revisarse cuidadosamente: strcpy(), strcat(), sprintf(), gets()... Muchas de ellas tienen contrapartidas con límite, como strncpy(), pero no siempre son exactamente equivalente, sobre todo en lo que respecta al "\0" de final de cadena. En general considero preferible hacer un chequeo con "strlen()" antes de utilizar esas funciones. O eso o poner "a mano" un "\0" en la máxima longitud permitida de la cadena a copiar.

    ¿De dónde provienen esos datos tan grandes?. Pues de todas las vías de información que el programa recoge a su alrededos. Hay cuatro puertas de entrada que hay que vigilar con cuidado, fundamentalmente:

    • La línea de comandos
    • Las variables de entorno
    • Los ficheros de datos que lee el programa
    • Los comandos remotos que te puede enviar un atacante

    A éstas hay que añadir las propias de cada programa, como el resultado de operaciones internas.

    Resulta muy conveniente repasar varias veces la comprobación de límites de nuestros programas ya que, en numerosas ocasiones y como es algo que no debería ejecutarse nunca en condiciones de explotación normales, el propio código de chequeo es incorrecto. Debemos recordar, también, que algunas funciones "estándar" pueden padecer de "buffer overflow" de forma interna; uno de los casos más famosos es "syslog()".

  5. Manejo de ficheros

    La gestión de ficheros es siempre una tarea delicada, ya que se presta a ataques locales de "race condition". Se produce una "race condicion" cuando varios procesos están compitiendo por un recurso, y entre dos operaciones de uno de ellos se le cuela otro que cambia las características del recurso. Supongamos, por ejemplo, el caso siguiente:

    if(access("/path/del/fichero",W_OK)==0) open(...);

    El código anterior verifica que el usuario que ejecuta el programa tenga acceso de escritura al fichero en cuestión. Si es el caso, el open lo abre para escritura y graba en él.

    Existe, sin embargo, una "race condition". Supongamos que un usuario malicioso crea un fichero válido, ejecuta el código anterior y, entre el "access()" y el "open()", borra el fichero y crea un enlace, pongamos, a "/etc/passwd". Las consecuencias pueden ser catastróficas. Evidentemente hace falta mucha casualidad para que las manipulaciones del usuario tengan lugar en el momento oportuno, pero no hay nada que le impida crear un proceso que lo intente repetidamente hasta tener éxito. A un ordenador no le cuesta nada intentarlo durante una semana seguida.

    Es por ello que "access()" es una función anticuada que no debería ser utilizada nunca, y menos en procesos SUID. En su lugar deben utilizarse funciones como "stat()","lstat()" y "fstat()", como se verá dentro de un momento. Lamentablemente estas funciones carecen de la propiedad de "access()" que permite validar el acceso en función del usuario real del proceso, no del usuario efectivo. Ello la hace muy conveniente en procesos SUID.

    La forma correcta de manejar un fichero es la siguiente:

    • Apertura de un fichero ya existente, como lectura o como "append":

      lstat()
      Vemos si es aceptable (enlace simbólico, dispositivo, fichero regular, etc).
      open()
      fstat()
      Comparar el "lstat()" con el "fstat()". Si son diferentes es que alguien ha tocado el fichero mientras tanto.

    • Creación de un fichero nuevo:

      open(O_CREAT|O_EXCL)

    • Truncado de un fichero:

      lstat()
      Vemos si es aceptable (enlace simbólico, dispositivo, fichero regular, etc).
      open()
      fstat()
      Comparar el "lstat()" con el "fstat()". Si son diferentes es que alguien ha tocado el fichero mientras tanto.
      ftruncate()

    • Borrado de un fichero:

      La forma más sencilla consiste en
      setegid(GID real)
      seteuid(UID real)
      unlink()
      seteuid(UID privilegiado)
      setegid(GID privilegiado)

    En cualquier caso es preciso verificar que el usuario real (no el efectivo) del proceso tenga permiso para efectuar esa operación. Se puede hacer un "setegid()", "seteuid()" al usuario real antes de la operación y volver a usuario SUID de nuevo, de la misma forma, una vez validado el acceso. Otra posibilidad es crear una "pipe", hacer un "fork()" y que sea el hijo, previos "setgid()" y "setuid()", quien acceda al fichero. Ello es especialmente importante si en el "path" hay directorios, ya que de otra forma se nos obligaría a verificar componente a componente. Ese es precisamente el reciente problema de seguridad de los servidores web bajo Windows NT y el "::$DATA".

    En los sistemas no POSIX que no tienen la opción de "saved uid/gid" se puede utilizar la llamada

    setreuid(geteuid(),getuid());

    para intercambiar el usuario real y efectivo.

    Como regla general, nunca crees o leas ficheros en directorios en los que pueda escribir todo el mundo. Y cuando crees ficheros, hazlo con los privilegios más restringidos que sea posible. Debemos recordar también que la verificación del acceso se realiza en el "open()", no en los "read()" y "write()". Ello permite, por ejemplo, abrir un fichero mientras somos privilegiados, despojarnos de los privilegios y seguir escribiendo en él.

  6. Gestión de bloqueos:

    La forma simple de hacer "locking" de ficheros consiste en utilizar la función "link()", que crea un enlace "hard" a un fichero ya existente. Ese enlace se puede borrar con un "unlink()". La ventaja de este sistema es que si el enlace ya existe, la llamada falla aunque seamos "root". Eso es muy importante, ya que llamadas como "creat()" tienen éxito si somos "root", aunque el fichero ya exista y especifiquemos modos "000".

    Otra forma clásica de crear un "lock" es crear un fichero de forma exclusiva, haciendo un "open(O_CREAT|O_EXCL)", aunque ello consume espacio en disco y hay que tener cuidado con que ningún otro proceso pueda escribir en él.

    Por último la mejor solución de todas es utilizar "flock()". Esta llamada permite crear bloqueos compartidos (lectura) y exclusivos (escritura), sin necesidad de pegarse creando ficheros marcadores por el disco. Además, los Unix modernos permiten hacer efectivos estos bloqueos incluso en una red NFS, por ejemplo.

    En general todos estos bloqueos son recomendaciones. Es decir, el programa puede optar por acceder al fichero aunque no haya adquirido previamente un bloqueo, o aunque el turno esté en posesión de otro proceso. En algunos sistemas "flock()" tiene potestad para forzar los bloqueos de forma efectiva, haciendo uso de un flag especial en los modos de acceso del fichero, normalmente el flag SGID sin que el grupo tenga permiso de ejecución. En ese caso, si un fichero está bloqueado, cualquier llamada "read()" o "write()" por parte de otro proceso fallará o será detenida hasta que se libere el bloqueo.

    En servidores "multithread" hay que proteger los recursos comunes con "mutex's".

  7. Ficheros temporales:

    En muchas ocasiones es preciso crear ficheros temporales de muy corta duración para, por ejemplo, servir de intercomunicación entre procesos. El lugar habitual es "/tmp", lo que siempre es delicado, entre otras cosas, porque ese directorio tiene permiso de escritura para todo el mundo. Con suerte el administrador habrá puesto el "sticky bit" en los modos de "/tmp", lo que evita que cualquiera nos los pueda borrar o renombrar, si los creamos con los permisos adecuados.

    Cuando es necesario evitar colisiones en el nombre, muchos procesos utilizan la función "mktemp()" para generar un nombre pseudoaleatorio. Sin embargo esta llamada está sujeta a "race condition" y realmente no asegura la unicidad, lo que hace imprescindible seguir los pasos indicados en el punto 5. Resulta mucho más conveniente el empleo de la función "tmpfile()", al menos cuando se dispone de una "libc" actualizada que cree el fichero con "O_EXCL". Además, esta rutina se encarga de borrar el fichero en cuestión cuando el programa termina o cierra el fichero.

    En las implementaciones recientes, "tmpfile()" hace lo siguiente:

    • Elegir un nombre único con "tmpnam()".
    • Crea el fichero con "O_EXCL" y retorna un "File Descriptor" ("fd").
    • Si el fichero ya existe, vuelve al primer punto.
    • Borra el fichero con "unlink()". Desaparece del directorio, pero sigue siendo accesible a través del "fd".
    • Ahora el programa puede escribir en el fichero usando el "fd".
    • Cuando el programa quiere leer el fichero, vuelve al principio con un "rewind()" o un "fseek()".
    • Cuando el programa termina o cierra el "fd", el fichero desaparece del todo.

    Este esquema tiene el inconveniente de que:

    1. El fichero, mientras sigue abierto, es invisible en el directorio pero ocupa espacio en el disco.
    2. No se puede utilizar para comunicarse con un proceso independiente, ya que no aparece en el directorio.

    Para la comunicación entre procesos a veces resulta rentable crear ficheros FIFO, que tienen un tratamiento especial por parte del Kernel.

  8. Ojo con los "core":

    Todos estamos famirializados con los "core", los ficheros de volcado de memoria que se generan cuando un programa tiene algún error fatal, como intentar acceder a memoria que no le pertenece. Los "core" son muy útiles, ya que con las herramientas adecuadas permiten estudiar la información "post-mortem" del programa y averiguar la causa del problema. Sin embargo no carecen de riesgos, como el hecho de que muchas veces no están sujetos a cuota, por ejemplo.

    Uno de los problemas más graves es que, en algunas versiones de Unix, cuando el Kernel genera un "core" sigue los enlaces simbólicos. Ello hace que cualquier usuario pueda destruir el contenido de cualquier fichero del sistema, sin más que localizar un fichero SUID a "root" que genere un "core" bajo determinadas circunstancias (por ejemplo, parámetros de entrada demasiado largos) y creando un enlace simbólico a "/etc/passwd", pongamos por caso. La mayor parte de los sistemas modernos borran primero el enlace, antes de grabar el "core".

    El otro problema es que el "core" genera todo un volcado de la memoria del proceso. Eso puede incluir claves, fragmentos del fichero "/etc/shadow" y demás información confidencial. Muchos Unix evitan el problema haciendo que no se genere "core" si los usuarios real y efectivo no coinciden. Lamentablemente, muchos procesos o bien liberan sus privilegios demasiado tarde, o bien no destruyen el contenido de búfferes "antiguos" que puedan contener información no pública.

    Se puede limitar el tamaño de los "core", o prohibirlos del todo, usando "ulimit" y "setrlimit()". En todo caso no es permisible que ningún programa en producción genere un core, sobre todo si se trata de un SUID...

    Si el Unix utilizado permite que un proceso con usuarios real y efectivo diferentes genere un "core", más vale hacer un "setrlimit()" y capturar todas las señales. Bajo Solaris, por cierto, teneis una función "gcore" muy interesante :-).

    Otro detalle a considerar es que el fichero "core" se graba con los modos especificados en "umask()". Las consecuencias se pueden ver en el punto siguiente.

  9. Cuidado con el "umask()" heredado:

    Cada proceso del sistema tiene un "umask()" heredado de su padre, que fija los privilegios por defecto cuando se crean ficheros sin modos explícitos. Como mínimo, la máscara debe estar fijada para inhibir la creación de ficheros con permiso de escritura a su grupo o a todo el mundo.

    Aunque el programa maneje ficheros de forma correcta es conveniente fijar unos mínimos para el "umask()" debido a posibles "core" y a posibles invocaciones por él de otros programas que sean menos cuidadosos.

    El tema de los "core" es especialmente delicado, porque un "core" con permiso de escritura permite que cualquier usuario llene una partición del disco si el propietario del demonio no tiene cuota asignada.

  10. El shell es demasiado listo:

    A veces recurrir al "shell" es tentador ya que nos evita codificar alguna tarea que él hace ya perfectamente. No obstante ello tiene sus riesgos. En particular el "shell" hace uso de variables de entorno que no están bajo nuestro control, además de que su gestión de los metacaracteres siempre es complicada. Por ejemplo, la ejecución de

    sprintf(buf,"/usr/bin/ls -la /var/mail/%s",usuario);
    system(buf);

    puede tener consecuencias catastróficas si el "usuario" nos inyecta "pepe; rm -r /*"

    En general es bastante mala política invocar al "shell", incluso aunque se filtren las variables de entorno (en especial $PATH y $IFS) y los metacaracteres. Hay que tener cuidado, por tanto, con funciones como "system()", "popen()" o la familia "exec*()".

    Si no hay más remedio que invocar un shell, hay que fijar las variables de entorno de forma explícita y especificar siempre el "path" completo a los comandos. Cualquier parámetro pasado debe ser comparado previamente con una lista de caracteres válidos. No, como hace mucha gente, con una lista de inválidos. De esta forma no se nos pasará ninguno.

  11. Cuidado con los valores suministrados por el usuario:

    Siempre hay que validar los datos que están bajo el control de los usuarios. Tenemos el caso flagrante de los "buffer overflow" descritos con anterioridad. Muchos CGIs, por ejemplo, esperan un determinado número de parámetros sin tener en cuenta que un usuario malicioso puede realizar una conexión HTTP válida con un sencillo "telnet" e introducir los datos que desee y en el formato que él quiera. Nunca debería pasarse al sistema ningún dato derivado de una entrada de usuario sin haberla validado antes (nombres de ficheros, comandos de shell, etc).

    No hay que fiarse nunca de cosas como nombres de DNS, ya que un usuario con acceso a un DNS inverso puede ponerse cualquier dirección. En esos casos hay que hacer siempre una doble comprobación:

    1. Tenemos la IP del usuario
    2. Obtenemos la dirección inversa
    3. Pedimos la lista de direcciones IP pertenecientes a esa inversa resuelta
    4. Pedimos la lista de direcciones ALIAS coincidentes con esa inversa
    5. Contrastamos la lista obtenida en el punto "c" con el punto "a"
    6. Si el punto anterior es válido, verificamos que alguno de los miembros de la lista en "c" o en "d" estén en nuestra lista de acceso.

    Hay muchas más cosas a tener en cuenta aquí: "argc" puede ser cero, algunos de los descriptores estándares (0-2) pueden estar cerrados o manipulados (y puede producirse el caos si el programa hace un "printf()", por ejemplo), el directorio actual puede ser ilegible o inexistente, puede existir un "alarm()" en curso al lanzar el programa, el proceso puede tener hijos que no ha creado (es normal si se invoca dentro de una "pipe"), etc. Hay que prepararse siempre para lo peor.

  12. Ojo con la propagación entre procesos:

    Aún cuando nuestro programa sea muy cuidadoso, puede llamar a otros que no lo sean tanto (o que sean maliciosos). Cualquier "file descriptor" abierto y que no deba pasarse al hijo debe cerrarse manualmente o configurarse para que lo haga el "exec*()", con las funciones "ioctl()" o "fcntl()". Una llamada "exec*()" debe estar precedida de una definición explícita de las variables de entorno. Un proceso privilegiado debe llamar a otros habiendo renunciado previamente a sus privilegios.

    Por supuesto hay que tener mucho cuidado con intercambiar información confidencial por la red, sin estar protegida convenientemente mediante criptografía.

  13. Librerías dinámicas:

    Si un usuario modifica las variables de entorno "LD_LIBRARY_PATH" y "LD_PRELOAD" puede hacer que cualquier programa ejecute código prácticamente arbitrario. Esas variables de entorno sólo son tenidas en cuenta si el programa invocado no es SUID (más bien debería decirse que no tendrían que tenerse en cuenta). Ello es bastante lógico, ya que en ese caso el usuario no ganaría ningún beneficio que no pueda obtener directamente. Pero incluso aunque el programa sea SUID existe un riesgo, ya que éste puede invocar a otros que no sean SUID y, por tanto, cuyo funcionamiento sí se vea afectado. La solución es la de siempre:

    • Cuando un proceso SUID llame programas externos, debe abandonar sus privilegios.
    • La llamada de procesos externos debe ser precedida de la declaración explícita de las variables de entorno.

    Lo más sencillo es que los programas SUID no utilicen librerías dinámicas, sino que se enlaces estáticamente al crearse el ejecutable. Con el compilador "gcc", por ejemplo, eso se consigue especificando "-static".

    A la hora de enfrentarse a programas SUID, cada enlazador dinámico ha evolucionado hasta casi converger:

    • Hasta la versión 1.6.7, ld-linux.so.1 obedece a las variables de entorno.
    • Hasta la versión 1.7.11, borra las variables de entorno de carga dinámica si el proceso es SUID/SGID.
    • A partir de la versión 1.9.0, vuelve a obedecer a las variables de entorno, pero sólo si las librerías especificadas no contienen ningún "/" en el nombre. Eso hace que las librerías se carguen de los ficheros configurados en el cargador ("/lib", etc).
    • El cargador dinámico de Solaris 2 hace exactamente lo mismo.
    • El cargador dinámico de GNU ignoraba las variables de entorno, pero a partir de 1.997 adoptó el mismo camino que el resto.
    • El enlazador dinámico de GNU distribuido en Red Had ignora las variables a menos que el que ejecute el código sea "root".

    Personalmente a mí me parecería buena idea hacer algo así:

    • Si no somos SUID, el usuario que ejecuta el proceso es su propietario o "root", obedece a la variables de entorno.
    • En caso contrario, y si las librerías no están en directorios seguros, no sólo no les hace caso, sino que borra las variables de entorno relativas al enlazado dinámico, por si el proceso llama luego a otros procesos no SUID.

    Algunas arquitecturas tienen más variables de entorno que afectan al enlazado dinámico. Por ejemplo "NLSPATH" en Solaris.

  14. Verificación exhaustiva y programación conservativa:

    Es conveniente comprobar siempre el resultado de cualquier llamada a función, aunque se suponga que nunca fallará. El programa debe funcionar adecuadamente siempre, aunque se ejecute bajo circunstancias adversas: sin memoria para los "malloc()", sin descriptores de ficheros libres, con el disco lleno, incapacidad para hacer un "fork()"... En caso necesario el código debe tener pruebas de consistencia interna y hacer logs exhaustivos (cuidado con provocar un buffer overflow en "syslog()"). El código debe releerse varias veces, dejando días entre lecturas y, a ser posible, recurrir a un auditor externo. Estudiar el programa con calma y plantearse "¿Qué ocurre si esta función, que no puede fallar nunca, falla?". Hay que ponerse en la situación de un atacante intentando descubrir una vulnerabilidad en propio código.

    En los procesos que se ejecutan con privilegios, la sección crítica de código debe ser lo más pequeña posible, y liberar los privilegios cuanto antes. Cuando sea posible, todos los "paths" a comandos y ficheros deben ser absolutos, y el programa debe fijar su "current directory" de una forma coherente.

    El programa debe estar diseñado, también, de forma que se eviten los "deadlocks" (bloqueos) y las "race conditions", y estar preparado para asumir y recuperarse del riesgo de bloqueos permanentes debido a la muerte o al mal funcionamiento de otro proceso. Hay que ser consciente de que cualquier secuencia de eventos dentro de nuestro programa puede ser intercalada con la ejecución de otros procesos. En el caso de GCIs, por ejemplo, hay que ser consciente de que pueden existir múltiples instancias del mismo proceso intentando acceder a recursos comunes. No basta con aplicar bloqueos; dependiendo de la aplicación puede ser preciso definir un mecanismo de limitación de carga: número de instancias concurrentes o segundos de CPU consumidos, por ejemplo.

    Cuando se compila el código debe hacerse siempre con el máximo nivel de "warning" que soporte nuestro compilador ya que, en muchas ocasiones, pueden detectarse operaciones incorrectas en tiempo de compilación. Es buena idea también el pasar el código fuente por alguna herramienta de verificación, como "lint".

    El programador debe dotar al programa de una documentación detallada del entorno de trabajo, detalles del código y detalles funcionales tales como: instalación, invocación, gestión de señales, opciones de la línea de comando y del fichero de configuración. Cuando se haga uso de algún privilegio especial debe indicarse claramente, así como describir las medidas tomadas para que dichos privilegios no se propaguen en caso de problemas.

    Por último, si el proceso se encuentra ante una situación aparentemente inresoluble, que no sea valiente. En casos así el programa debe grabar un log y terminar su ejecución limpiamente, borrando los ficheros temporales, etc. Por nada del mundo debe intentar salvar la situación; hacer funcionar a un programa fuera de sus parámetros operacionales es, en muchas ocasiones, peligroso y más contraproducente que simplemente no correr el servicio.



Donación BitCoin: 19niBN42ac2pqDQFx6GJZxry2JQSFvwAfS

Actualizar Python Zope ©1998-99 jcea@jcea.es