Programación y seguridad: La importancia del control de errores

Borja Marcos

Una de las noticias del día ha sido, sin duda, la monumental metedura de pata de Apple en la autentificación de usuarios en Mac OS X. Resumido de manera muy simple, es posible autentificarse como un usuario con la cuenta bloqueada haciendo varios intentos con una clave en blanco. El problema es que, precisamente, una de las cuentas bloqueadas en Mac OS X es la de “root”, el todopoderoso superusuario.

Evitar este tipo de problemas no es en absoluto fácil, porque se trata de un problema en el control de errores. Se ha producido una excepción en la verificación de clave y el sistema ha aprobado un acceso en lugar de denegarlo

Para explicar mejor qué ha ocurrido se puede poner como ejemplo un problema prácticamente igual que encontré en 1991 en un producto llamado Atlantix Axcess. Se trataba de un servidor Lan Manager que funcionaba en SCO Unix y servía para lo mismo para lo que en la época se empleaba Netware, de Novell. Servicio de almacenamiento de archivos en red para PCs.

Los sistemas Unix (Mac OS X lo es) almacenan las contraseñas cifradas en un archivo. Cuando un usuario solicita autorización el sistema cifra la contraseña introducida y la compara con la versión cifrada que tiene almacenada. Si ambas son iguales significa que el usuario ha tecleado la clave correcta.

Visto en pseudocódigo sería algo así.  

 función autentificar(usuario, clave)
    almacenada = leer_clave(usuario)
    cifrada = cifrar (clave)
    comparar(cifrada, almacenada)
    Si distintas, devolver NO
    Si no, devolver SI
Es una simplificación burda, por supuesto, y en realidad el funcionamiento de esto es un pelín más complicado.

Para evitar los llamados “ataques de diccionario”, Unix emplea un mecanismo llamado salt que consiste en seleccionar uno entre un montón de algoritmos o claves de cifrado de un sólo uso. Cuando un usuario cambia su clave el sistema selecciona aleatoriamente una variante de cifrado, codifica la clave con la variante seleccionada y almacena ambas juntas. Históricamente, concatenando clave y salt.

Esto tiene una consecuencia muy importante: la contraseña cifrada tiene estructura. Se trata de dos elementos: salt y clave cifrada. 

Una versión un poco más fiel de nuestro pseudocódigo sería, por tanto, 
 función autentificar(usuario, clave)
    (almacenada, salt) = leer_clave(usuario)
    cifrada = cifrar (clave, salt)
    comparar(cifrada, almacenada)
    Si distintas, devolver NO
    Si no, devolver SI 

Tradicionalmente en Unix se ha utilizado una forma muy simple para bloquear una cuenta de usuario de manera que ninguna clave de acceso pueda coincidir. Basta con hacer algo tremendamente simple, que es poner una clave cifrada imposible. Generalmente un asterisco: “*”

Veamos un ejemplo:
   daemon:*:1:1:System Services:/var/root:/usr/bin/false

El segundo campo, después del nombre, sería la clave cifrada. El algoritmo de cifrado de claves de Unix siempre devuelve una clave cifrada de tamaño fijo. Por tanto, algo como un asterisco o algo tan simple como “NO” permite bloquear la cuenta de manera efectiva. 

Sin embargo, parece que la función que hace la comprobación la escribió alguien muy meticuloso y poco familiarizado con las particularidades del manejo de errores en sistemas de autentificación. Imaginemos algo como esto:

 función leer_salt(almacenada, *salt)
    *salt = coger_primeros_caracteres(almacenada)
    Si error
       devolver ERROR
    si no,
       devolver OK
 
 función autentificar(usuario, clave)
    almacenada = leer_clave(usuario)
    todo_bien = leer_salt(almacenada, &salt)
    si todo_bien
       clave_almacenada = leer_cifrada(almacenada)
       cifrada = cifrar(clave, salt)
       comparar(cifrada, clave_almacenada)
       si iguales
          devolver OK
       si no
          devolver ERROR

Por supuesto la función “autentificar” está mal, porque el código de retorno puede ser cualquier cosa. De hecho cualquier compilador de C moderno protestará si se encuentra algo así. Pero los compiladores de 1991 no eran ni mucho menos tan listos. Por otro lado, esta versión del problema de 1991 es una mala práctica que puede ser habitual en C. En un lenguaje orientado a objeto como Objective C o Python este problema se puede complicar mucho debido al mecanismo de las excepciones. El manejo de errores en C con frecuencia es una pesadilla pero al menos tenemos una ventaja: generalmente el control de errores se lleva a cabo cerca, digamos, del código en el que se ha producido.

Una excepción, sin embargo, puede ser “atrapada” varias llamadas a subrutina por encima, lo que en el peor de los casos puede significar que esto se haga desde una función escrita por un programador diferente. Si la documentación no especifica correctamente estos comportamientos puede producirse una confusión que de lugar a un error similar a éste.

En el caso de SCO Unix no era posible acceder a la cuenta de superusuario directamente (tenía clave asignada) pero sí era posible acceder a otras cuentas privilegiadas como “bin” (propietario de los archivos del sistema) o “tcb” (propietario las bases de datos de autentificación y permisos). En ambos casos, una vez conseguido el acceso era trivial llegar a la cuenta del superusuario, claro.

Lo interesante y anti intuitivo de este ejemplo es que en la función de autentificación es mejor ignorar el error al leer el salt. En un programa en C tendríamos una variable salt con un contenido aleatorio y, en este caso, el cifrado de la contraseña introducida por el usuario sería necesariamente distinta del asterisco presente en el archivo de contraseñas (recordemos que siempre devolverá una cadena de un número fijo de caracteres, digamos, 14). En ese caso, por tanto, a pesar de hacer algo tan feo como ignorar un error el funcionamiento sería correcto en los tres casos:

  • Contraseña correcta y cuenta válida
  • Contraseña incorrecta y cuenta válida
  • Cuenta bloqueada (obviamente cualquier contraseña es no válida por definición)

Aunque no pase de simple curiosidad se trata de un interesante ejemplo de las consecuencias imprevistas del tratamiento de errores que complementa muy bien otro ejemplo, esta vez sobre las consecuencias potenciales de una optimización, descrito por A. S. Tanenbaum en su libro “Sistemas Operativos: Diseño e Implementación”. En este último caso, una comparación de cadenas muy eficiente facilitaba enormemente la búsqueda de claves por fuerza bruta en el sistema operativo TENEX.

No deja de ser curioso lo sutil que puede llegar a ser un fallo de seguridad de programación.
 

Contacta ahora con Sarenet

 

Sobre este Autor

Borja Marcos | Responsable de Seguridad en Sarenet

Enviar una respuesta

No hay comentarios