Apuntes · Capítulo 16Seguridad

Seguridad: autenticación, control de acceso y criptografía

Seguridad: autenticación, control de acceso y criptografía

Durante quince semanas construimos mecanismos para que muchos programas compartan una máquina sin estorbarse: modo usuario y modo kernel, tablas de páginas, locks. Casi todos tienen una segunda lectura: la tabla de páginas que le da a cada proceso su espacio de direcciones (address space) es también lo que le impide leer la memoria de otro. Esta semana miramos el sistema operativo con los ojos de alguien que quiere romperlo.

Ruta de lectura.

  • Antes de clase (15–20 min): la primera parte de «Qué significa “seguro”» (hasta «El monitor de referencia y la TCB») y «Los permisos de Unix».
  • En clase: sesión A, «Principios de diseño», «No confiar en los punteros del usuario», «Autenticación» y «Control de acceso»; sesión B, «Explotación de software y endurecimiento».
  • Después de clase: repasar el «Resumen», leer «Cierre del curso» y resolver los «Ejercicios». De lectura: «Criptografía para el sistema operativo» y «Seguridad en sistemas distribuidos». Para ampliar: OSTEP cap. 52 a 57.

Lectura. Este capítulo acompaña a OSTEP cap. 52 a 55 (Security, Authentication, Access Control) y cap. 56 y 57 (Cryptography, Distributed System Security). El código está en code/week-16; make test dentro del contenedor crea los usuarios y archivos de prueba en /tmp/w16 y corre todos los ejemplos.

Qué significa “seguro”

Se suele resumir la seguridad en tres metas, conocidas por sus siglas en inglés, CIA:

  • Confidencialidad (confidentiality): solo quien debe puede leer la información. Que otro alumno no lea sus notas.
  • Integridad (integrity): solo quien debe puede modificar la información, y si alguien la altera, se nota. Que nadie cambie sus notas.
  • Disponibilidad (availability): el sistema responde cuando se le pide. Que un programa con un bucle infinito no deje a todos sin CPU.

Hay que decir además contra quién se defiende el sistema: el modelo de amenazas (threat model). En una computadora del laboratorio, el atacante es otro alumno con cuenta normal: ejecuta programas como su usuario e intenta adivinar contraseñas, pero no puede llevarse el disco. Si pudiera, haría falta cifrarlo.

Idea clave. La separación entre política y mecanismo que vimos en la semana 1 reaparece aquí. La política dice qué está permitido (“solo Ana lee sus notas”); el mecanismo lo hace cumplir (bits de permiso, tablas de páginas). Un mecanismo perfecto con una política equivocada no protege nada.

El monitor de referencia y la TCB

Cada vez que un proceso (el sujeto) quiere hacer algo con un recurso (el objeto), alguien tiene que decidir si se permite. Ese alguien es el monitor de referencia (reference monitor): un componente que recibe la solicitud, consulta la política y permite o niega. En Unix, cuando un proceso llama a open("nota.txt", O_RDONLY), el monitor es el código del kernel que mira el inodo, compara su owner y sus bits con la identidad del proceso y devuelve un descriptor o el error EACCES.

Para que el monitor sirva, se le piden tres cosas:

  1. Mediación completa: se consulta en cada acceso, sin atajos.
  2. A prueba de alteraciones: el código que se está controlando no puede modificarlo. Por eso vive en el kernel, protegido por el modo kernel y las tablas de páginas.
  3. Verificable: es lo bastante pequeño como para revisarlo con cuidado.

Todo lo que tiene que funcionar bien para que se cumpla la política forma la base de cómputo confiable (trusted computing base, TCB): hardware, kernel, programas privilegiados (login, sshd, sudo, passwd) y sus archivos (/etc/shadow, /etc/sudoers). Un error en la TCB afecta a todos; por eso se la quiere pequeña.

Un proceso de ana en modo usuario llama a open sobre nota.txt; la llamada cruza la frontera hacia el kernel, donde el monitor de referencia consulta el inodo de nota.txt (owner ana, rw-r-----) y devuelve fd = 3 o EACCES. El monitor, el inodo, los programas privilegiados y el hardware tienen borde dorado: forman la TCBUn proceso de ana en modo usuario llama a open sobre nota.txt; la llamada cruza la frontera hacia el kernel, donde el monitor de referencia consulta el inodo de nota.txt (owner ana, rw-r-----) y devuelve fd = 3 o EACCES. El monitor, el inodo, los programas privilegiados y el hardware tienen borde dorado: forman la TCB
El monitor de referencia vive del lado del kernel de la frontera: el proceso solo llega al archivo a través de él. Lo que tiene borde dorado es la TCB.

Principios de diseño

En 1975, Saltzer y Schroeder resumieron la experiencia de los primeros sistemas multiusuario en ocho principios que se siguen citando:

Principio En una frase
Economía de mecanismo Simple y pequeño, para poder revisarlo.
Valores por defecto seguros Sin permiso explícito, se niega.
Mediación completa Se verifica cada acceso.
Diseño abierto El atacante conoce el diseño; el secreto está solo en las claves.
Separación de privilegios Exigir dos condiciones independientes.
Mínimo privilegio Cada proceso con lo justo, y solo mientras lo necesita.
Mínimo mecanismo común Compartir lo menos posible entre usuarios.
Aceptabilidad Si la seguridad es incómoda, la gente la evita.

Van a reconocerlos en el capítulo: la autenticación multifactor es separación de privilegios, y un programa setuid root innecesario viola el mínimo privilegio.

En xv6. xv6-riscv no tiene un modelo de seguridad en el sentido de este capítulo, pero sí tiene aislamiento, y está construido con lo que ya conocen. En kernel/riscv.h, el bit PTE_U (1L << 4, “user can access”) marca las páginas que el modo usuario puede tocar. El hardware revisa ese bit en cada acceso: es el monitor de referencia de la memoria. El trampoline está mapeado en todos los espacios de direcciones, pero sin PTE_U (ver proc_pagetable en kernel/proc.c), y exec usa uvmclear (kernel/vm.c) para quitarle el PTE_U a una página de guarda debajo de la pila: si hay un stack overflow, el programa provoca un fallo de página en lugar de pisar sus datos.

No confiar en los punteros del usuario

Los argumentos de una llamada al sistema los elige el programa. Si read(fd, buf, n) escribiera en buf sin mirar, un programa pasaría la dirección de una estructura del kernel y la sobrescribiría.

En xv6. El kernel de xv6 corre con su propia tabla de páginas, así que un puntero de usuario ni siquiera significa algo ahí. Para leerlo, el kernel lo traduce en software con la tabla del proceso. copyin y copyout en kernel/vm.c recorren esa tabla con walkaddr, que devuelve 0 si la página no es válida o no tiene PTE_U; copyout además rechaza páginas sin PTE_W. En kernel/syscall.c, fetchaddr comprueba que la dirección esté dentro del tamaño del proceso antes de copiar:

if (addr >= p->sz ||
    addr + sizeof(uint64) > p->sz) // both tests needed, in case of overflow
  return -1;

y argstr copia el nombre de archivo de open con fetchstr y copyinstr, con un tope de MAXPATH bytes. Las dos comparaciones de fetchaddr no son redundantes: si addr es casi 2^64, la suma da la vuelta y la segunda sola dejaría pasar la dirección.

Autenticación

El kernel no sabe quién está frente al teclado; sabe el uid de cada proceso. La autenticación (authentication) ocurre antes: login o sshd comprueban la identidad y crean una shell con ese uid. Para el kernel, el uid es la identidad.

Hay tres tipos de prueba:

  • Algo que sabes: una contraseña, un PIN.
  • Algo que tienes: un celular, una llave USB de seguridad.
  • Algo que eres: una huella, el rostro.

La autenticación multifactor (MFA) combina dos tipos distintos. Con TOTP, el celular y el servidor comparten una clave y calculan un código de seis dígitos a partir de ella y de la hora: quien roba la contraseña todavía no tiene el celular. Dos contraseñas no son dos factores.

Un llavero con llaves de metal y una llave USB de seguridad negra; al lado, otra llave de seguridad nueva en su empaque
Algo que tienes: una llave de seguridad USB va en el llavero junto a las llaves de la casa. Para entrar hace falta tenerla conectada y tocarla. Foto: Daniel Aleksandersen, CC BY 4.0, Wikimedia Commons.

Cómo guardar contraseñas

El sistema tiene que guardar algo para comprobar la contraseña. Veamos qué pasa, en cada opción, si un atacante roba ese archivo.

  1. En claro. El atacante tiene todas las contraseñas.
  2. Un hash de la contraseña. Un hash criptográfico H es una función de un solo sentido: fácil de calcular, imposible en la práctica de invertir. Para verificar se calcula H de lo que escribió el usuario y se compara. Pero el atacante calcula H del diccionario una sola vez y lo compara con todos los usuarios; además, contraseñas iguales dan hashes iguales.
  3. Hash con salt. Para cada usuario se elige un valor aleatorio, la salt (sal), y se guarda s junto con H(s ‖ contraseña). La salt no es secreta: se guarda en claro. Su trabajo es que cada usuario sea un problema aparte. El cálculo hecho para Ana no sirve para Dante, aunque los dos usen alpaca.
  4. Hash lento con salt. Funciones caras a propósito, como yescrypt, o un hash repetido miles de veces, como el SHA-512 de crypt. Unos milisegundos no molestan al usuario; al atacante se le multiplican por cada intento.
Arriba, sin salt: ana y dante usan alpaca y los dos quedan con el mismo hash, que coincide con H(alpaca) de un diccionario precalculado. Abajo, con salt: cada uno tiene una salt distinta, los hashes salen distintos y el diccionario precalculado no coincide con ningunoArriba, sin salt: ana y dante usan alpaca y los dos quedan con el mismo hash, que coincide con H(alpaca) de un diccionario precalculado. Abajo, con salt: cada uno tiene una salt distinta, los hashes salen distintos y el diccionario precalculado no coincide con ninguno
Sin salt, un solo cálculo de H(alpaca) delata a todos los que usan alpaca. Con salt, el atacante tiene que repetir el diccionario para cada usuario, aunque una contraseña del diccionario sigue cayendo.

/etc/shadow y crypt(3)

En Linux, /etc/passwd es legible por todos y en el campo de la contraseña solo dice x. Los hashes están en /etc/shadow, que solo puede leer root (en el contenedor: rw-r----- root shadow). Ocultar los hashes es una capa más, pero el diseño supone que alguna vez se van a filtrar.

Esta línea salió del contenedor del curso con echo 'ana:alpaca' | chpasswd y getent shadow ana:

ana:$y$j9T$eGDoGUacdE5./GRn65M861$.jZuASmYzKfJf8mynEoYt9uea9/TWi.KROuQeP/8lL/:20755:0:99999:7:::

Los campos están separados por :. El segundo tiene el formato $id$parámetros$salt$hash: $y$ indica yescrypt (el algoritmo que configura PAM en /etc/pam.d/common-password de Ubuntu), j9T codifica los parámetros de costo, luego viene la salt y al final el hash. Un prefijo $6$ indicaría SHA-512 iterado. Los números del final son fechas de cambio y de vencimiento.

crypt(3) (libxcrypt) recibe la contraseña y una “configuración” con el prefijo y la salt, y devuelve la línea completa. Para verificar se le pasa el hash guardado como configuración, porque ya trae algoritmo y salt.

Animación: de la línea de ana en /etc/shadow baja la salt; junto con lo que escribió ana, alpaca, entra a crypt(), que toma el algoritmo y el costo de la misma línea; sale un hash que se compara con el guardado, y como son iguales la contraseña es correctaAnimación: de la línea de ana en /etc/shadow baja la salt; junto con lo que escribió ana, alpaca, entra a crypt(), que toma el algoritmo y el costo de la misma línea; sale un hash que se compara con el guardado, y como son iguales la contraseña es correcta
Verificar no es descifrar: crypt() repite el cálculo con la salt guardada y compara el resultado con el hash guardado.
char *salt = crypt_gensalt("$y$", 0, NULL, 0);   // salt aleatoria
strcpy(stored, crypt(pw, salt));

char *h = crypt(intento, stored);                 // verificar
if (strcmp(h, stored) == 0)
    puts("correcta");

hashpw.c se compila con -lcrypt. Si lo ejecutan con la misma contraseña, cada línea yescrypt sale distinta, porque cada salt es distinta.

Ejemplo resuelto: el costo de un ataque de diccionario

crack.c ataca los hashes de cuatro usuarios con un diccionario de 12 palabras: Ana y Dante usan alpaca, Beto lima y Carla x9#Tq!vR2m. Sin salt (una salt fija para todos), calcula 12 hashes una vez y los compara con todos: 12 llamadas a crypt. Con salt recorre el diccionario por usuario: 9 + 8 + 12 + 9 = 38 llamadas. Las dos veces cae todo menos la contraseña de Carla, que no está en el diccionario.

Escalemos: |D| = 10^6 palabras y |U| = 10^4 usuarios. Un hash rápido como SHA-256 va del orden de 10^7 por segundo en un núcleo; en el contenedor medimos 1,1 ms por llamada a crypt con SHA-512 ($6$) y 8,8 ms con yescrypt.

Caso Hashes a calcular Tiempo en un núcleo
Sin salt, hash rápido 10^6 10^6 / 10^7 ≈ 0,1 s
Con salt, hash rápido 10^6 × 10^4 = 10^10 10^10 / 10^7 = 1000 s ≈ 17 min
Con salt, SHA-512 crypt 10^10 10^10 × 1,1 ms ≈ 1,1 × 10^7 s ≈ 4 meses
Con salt, yescrypt 10^10 10^10 × 8,8 ms ≈ 8,8 × 10^7 s ≈ 3 años

Una GPU puede calcular entre 10^9 y 10^10 hashes rápidos por segundo, lo que convierte los 17 minutos en un instante. yescrypt usa además mucha memoria por intento, y por eso una GPU gana poco frente a él.

Idea clave. La salt no salva a una contraseña que está en el diccionario. Si el atacante va por un usuario en particular, con yescrypt necesita 10^6 × 8,8 ms ≈ 2,5 horas. Lo que de verdad protege es una contraseña larga que no esté en ninguna lista, y un segundo factor.

Comparar en tiempo constante

Para comparar un secreto (un token, un MAC) con lo que envía el usuario, la comparación ingenua sale en el primer byte distinto:

for (int i = 0; i < LEN; i++)
    if (a[i] != b[i])
        return 0;
return 1;

Entonces el tiempo delata cuántos bytes iniciales acertó el atacante. timing.c lo mide con un secreto de 64 bytes: la versión ingenua tarda 2,1 ns si el primer byte falla, 32,9 ns con 32 aciertos y 73,7 ns con 63, mientras que la de tiempo constante se queda en unos 118 ns. Un atacante que pueda medirlo adivina byte por byte, con a lo sumo 64 × 256 = 16 384 intentos en lugar de 256^64. A esto se le llama un canal lateral (side channel). La versión de tiempo constante recorre siempre todo y acumula las diferencias con diff |= a[i] ^ b[i]. Su tiempo no depende del contenido.

Control de acceso

El control de acceso (access control) decide qué puede hacer cada uid. Conceptualmente, la política es una matriz de acceso: una fila por sujeto, una columna por objeto y en cada celda los derechos (Ana: rw sobre nota.txt). Nadie la guarda entera, porque sería enorme y casi vacía. Hay dos formas de partirla.

  • Por columnas: listas de control de acceso (ACL). Cada objeto guarda la lista de quién puede hacer qué con él. Es el portero de una fiesta con la lista de invitados.
  • Por filas: capabilities. Cada sujeto guarda los tokens que le dan acceso a objetos. Es la entrada del cine: quien la tiene, pasa.
Matriz de acceso con sujetos ana, beto y root y objetos nota.txt, raro.txt y secreto.txt. La columna de nota.txt está marcada como ACL, guardada con el objeto; la fila de ana está marcada como capabilities, que lleva el sujetoMatriz de acceso con sujetos ana, beto y root y objetos nota.txt, raro.txt y secreto.txt. La columna de nota.txt está marcada como ACL, guardada con el objeto; la fila de ana está marcada como capabilities, que lleva el sujeto
Una ACL es una columna de la matriz; las capabilities de un sujeto son una fila.
ACL Capability
Dónde vive En el objeto (el inodo) En el sujeto (el proceso)
¿Quién accede a X? Fácil: leer la lista Difícil: revisar a todos
¿A qué accede Ana? Difícil: recorrer todos los objetos Fácil: sus tokens
Revocar Fácil: borrar la entrada Difícil: el token ya se copió y entregó
Requisito Saber con certeza quién es el sujeto Que el token no se pueda falsificar

Unix usa una mezcla. open revisa la ACL del archivo y, si se permite, entrega un descriptor de archivo, que funciona como una capability: los read y write siguientes no vuelven a revisar los permisos, y el descriptor se hereda con fork o se puede pasar a otro proceso. Por eso, si alguien hace chmod para quitar un permiso, un proceso que ya tenía el archivo abierto puede seguir usándolo.

Los permisos de Unix

La ACL clásica de Unix tiene solo nueve bits, en tres clases: owner (user), grupo (group) y otros (others), con lectura (r = 4), escritura (w = 2) y ejecución (x = 1) para cada clase. rw-r----- es 640: el owner lee y escribe, el grupo lee y los demás no pueden hacer nada. En un directorio, r permite listar, w crear y borrar entradas, y x atravesarlo para llegar a lo que contiene.

  • chmod 640 nota.txt cambia los bits; chown ana:curso nota.txt, el owner y el grupo (el owner solo lo cambia root).
  • umask indica qué bits se quitan al crear archivos. Con umask 022, open(..., O_CREAT, 0666) crea el archivo con 0666 & ~022 = 0644, es decir rw-r--r--. Con umask 077 los archivos nuevos quedan en 600.

El algoritmo de verificación tiene una sutileza: se elige una sola clase, la primera que coincide, y solo se miran sus tres bits.

  1. Si el uid efectivo es 0 (root), se permite leer y escribir. Para ejecutar basta que algún bit x esté encendido.
  2. Si el uid efectivo es el owner del archivo, se usan los bits de owner.
  3. Si el grupo del archivo es uno de los grupos del proceso, se usan los bits de grupo.
  4. Si no, se usan los bits de otros.
Animación: raro.txt tiene permisos ---r--r-- y es de ana. La ficha de ana baja por las preguntas: no es root, sí es owner, así que se usan los bits de owner, ---, y se niega la lectura. La ficha de beto no es root, ni owner, ni del grupo, así que se usan los bits de otros, r--, y puede leerAnimación: raro.txt tiene permisos ---r--r-- y es de ana. La ficha de ana baja por las preguntas: no es root, sí es owner, así que se usan los bits de owner, ---, y se niega la lectura. La ficha de beto no es root, ni owner, ni del grupo, así que se usan los bits de otros, r--, y puede leer
Se elige una sola clase, la primera que coincide. Ana, que es owner, no puede leer raro.txt aunque cualquier otro sí.

En perms.c está ese algoritmo, y el programa lo compara con la respuesta del kernel (eaccess(2), que usa los IDs efectivos como open). El archivo raro.txt tiene modo 044 (---r--r--) y es de Ana:

$ setpriv --reuid=ana --regid=ana --init-groups ./perms /tmp/w16/raro.txt
uid=1001 euid=1001 gid=1002
/tmp/w16/raro.txt  0044 ---r--r--  owner=1001 grupo=1002
  leer     clase=owner nuestro=no kernel=no

Ana es owner del archivo: se usan los bits de owner, ---, y se niega la lectura aunque todos los demás puedan leer. Así se puede excluir al owner o a un grupo.

setuid: correr con la identidad de otro

Cada proceso tiene un uid real (ruid: quién lo lanzó) y un uid efectivo (euid: con quién se verifican los permisos). Si un ejecutable tiene el bit setuid (la s en rwsr-xr-x), exec cambia el euid al owner del archivo. Así passwd, que es de root, puede escribir /etc/shadow cuando lo ejecuta Ana. setgid hace lo mismo con el grupo.

ids.c imprime getuid() y geteuid(), intenta abrir un archivo de root, suelta los privilegios y vuelve a intentar. Instalado con chown root ids; chmod 4755 ids y ejecutado por Ana:

$ setpriv --reuid=ana --regid=ana --init-groups /tmp/w16/ids /tmp/w16/secreto.txt
al empezar:    uid=1001 euid=0  open(/tmp/w16/secreto.txt): ok
tras soltar:   uid=1001 euid=1001  open(/tmp/w16/secreto.txt): no se puede
volver:        seteuid(0) falla: los privilegios se perdieron

Cuando el euid es 0, setuid(getuid()) cambia los uid real, efectivo y guardado, así que no hay vuelta atrás. Es el patrón del mínimo privilegio: hacer lo que requiere root y soltarlo de inmediato.

Animación: la shell de ana tiene ruid y euid 1001; el ejecutable ids es de root y tiene el bit s (-rwsr-xr-x). Tras exec, el proceso ids tiene ruid 1001 y euid 0, y open(secreto.txt) funciona. Tras setuid(1001), ruid y euid son 1001, open(secreto.txt) ya no se puede y seteuid(0) fallaAnimación: la shell de ana tiene ruid y euid 1001; el ejecutable ids es de root y tiene el bit s (-rwsr-xr-x). Tras exec, el proceso ids tiene ruid 1001 y euid 0, y open(secreto.txt) funciona. Tras setuid(1001), ruid y euid son 1001, open(secreto.txt) ya no se puede y seteuid(0) falla
exec de un programa con el bit s cambia solo el uid efectivo, que es el que se verifica. setuid(getuid()) lo devuelve al usuario real y ya no hay vuelta atrás.

Un programa setuid root es peligroso porque corre como root mientras sus entradas (argumentos, entorno, archivos) las controla otro usuario: un buffer sin límite o un PATH confiado bastan para que ese usuario ejecute lo que quiera como root. Cada uno es parte de la TCB (find / -perm -4000 los lista). sudo es uno de ellos: vuelve a autenticar, consulta /etc/sudoers y registra cada uso.

Idea clave. Un programa setuid root que solo necesita abrir un archivo viola el mínimo privilegio: da todo el poder de root durante toda la ejecución. Alternativas: un grupo que sea owner del archivo y el programa setgid, o abrir el archivo y soltar root en la línea siguiente.

ACLs POSIX

Las ACLs POSIX agregan entradas para usuarios y grupos con nombre, sin crear un grupo nuevo:

$ setfacl -m u:beto:r /tmp/w16/nota.txt
$ getfacl -c /tmp/w16/nota.txt
user::rw-
user:beto:r--
group::r--
mask::r--
other::---
$ ls -l /tmp/w16/nota.txt
-rw-r-----+ 1 ana curso 16 Oct 29 12:27 /tmp/w16/nota.txt

El + de ls avisa que hay una ACL. La entrada mask es el tope de permisos para las entradas con nombre y para el grupo, y cuando hay ACL los bits de grupo que muestra ls -l son en realidad la máscara. Ahora perms ejecutado como Beto dice clase=otros nuestro=no kernel=sí: el kernel revisa owner, usuarios con nombre, grupos y otros, y nuestro algoritmo de nueve bits no ve la entrada de Beto. Moraleja: para saber si hay acceso se le pregunta al kernel, no se reimplementa la verificación.

Capabilities, DAC y MAC

Ojo con el nombre: las capabilities de Linux (CAP_*) no son las de la matriz de acceso, sino el poder de root dividido en unos cuarenta privilegios. CAP_NET_BIND_SERVICE, por ejemplo, permite usar el puerto 80 sin ser root: mínimo privilegio para programas que antes eran setuid root.

Todo lo anterior es control de acceso discrecional (DAC): el owner decide. En el control de acceso obligatorio (MAC) una política del sistema, que ni el owner puede cambiar, limita a cada programa: con SELinux o AppArmor, “el servidor web solo lee /var/www”. El control basado en roles (RBAC) asigna permisos a roles (administrador, docente, alumno) en lugar de a personas.

En xv6. xv6 no tiene usuarios ni permisos. struct dinode en kernel/fs.h solo tiene type, major, minor, nlink, size y addrs: no hay owner ni modo. struct stat (kernel/stat.h) tampoco, y struct proc no tiene uid. sys_open en kernel/sysfile.c copia la ruta con argstr y abre el archivo sin preguntar nada: cualquier proceso puede abrir cualquier archivo. Lo único que protege xv6 es el aislamiento entre procesos y entre usuario y kernel. Agregar usuarios requeriría un uid en struct proc que fork herede, un uid y un modo en el inodo, la verificación en sys_open y en exec, una llamada setuid y un programa login.

Explotación de software y endurecimiento

Acabamos de ver que un programa setuid root es peligroso porque corre con todos los permisos mientras sus entradas las controla otro usuario. Toca ahora la pregunta concreta: ¿cómo, exactamente, una entrada mal manejada le da a ese usuario el control del programa? La respuesta explica por qué el sistema operativo y el compilador traen las defensas que traen. El objetivo aquí es entenderlas, no escribir ataques: todos los ejemplos son locales y seguros.

Buffer overflow en la pila

Cuando una función se ejecuta, sus variables locales viven en un marco de pila (stack frame). En el modelo clásico, para una función con un arreglo char buf[16], el marco contiene, de direcciones bajas a altas: el propio buf, un canario (si el compilador lo puso), el frame pointer guardado y la dirección de retorno: adónde salta la CPU cuando la función termina. La pila crece hacia direcciones bajas, pero una copia dentro de buf escribe hacia direcciones altas, es decir, hacia el canario y la dirección de retorno.

strcpy(buf, entrada) copia hasta encontrar un \0, sin mirar cuánto espacio hay en buf. Si entrada mide más que buf, la copia sigue de largo y pisa lo que haya encima. Lo mismo hacen gets, sprintf o un memcpy con un tamaño equivocado. Si lo que se pisa es una dirección de retorno guardada, al retornar de ese marco la CPU salta a donde diga ahora ese valor.

Animación: un marco de pila con cuatro celdas (de arriba hacia abajo: dirección de retorno, frame pointer guardado, canario, buf[16]). strcpy copia una cadena de A (0x41) que llena buf y sigue hacia arriba, pisando el canario, el frame pointer y la dirección de retornoAnimación: un marco de pila con cuatro celdas (de arriba hacia abajo: dirección de retorno, frame pointer guardado, canario, buf[16]). strcpy copia una cadena de A (0x41) que llena buf y sigue hacia arriba, pisando el canario, el frame pointer y la dirección de retorno
Marco clásico (x86-64): la copia sin medir llena buf y sigue hacia direcciones altas, pisa el canario, el frame pointer guardado y la dirección de retorno. Al retornar, la CPU salta a 0x4141…

smash.c lo muestra sin montar ningún ataque: un manejador de señal informa a qué dirección saltó la CPU al retornar. Compilado sin canario (-fno-stack-protector), una entrada de 30 bytes pisa una dirección de retorno guardada y el salto falla:

$ setarch -R ./smash in_overflow      # 30 bytes 'A' = 0x41
copie 30 bytes en un buffer de 16
[SIGBUS] la CPU intento ejecutar en 0x414141414141

Los 0x41 son el código ASCII de A: la dirección de retorno quedó llena de las letras que copiamos. Un atacante que controle esos bytes elige a qué dirección salta el programa; el ejercicio clásico de laboratorio (que aquí no hacemos) es ponerle ahí la dirección de una función del mismo programa que nunca se llama. Compilado con canario (-fstack-protector), el valor aleatorio que el compilador puso entre buf y la dirección de retorno se comprueba en el epílogo de la función (una llamada a __stack_chk_fail de la libc), y como quedó pisado, el programa aborta:

$ setarch -R ./smash_ssp in_overflow
copie 30 bytes en un buffer de 16
*** stack smashing detected ***: terminated

Idea clave. La memoria no distingue datos de direcciones: buf y la dirección de retorno son bytes contiguos en la pila. La vulnerabilidad no es “C es inseguro” sin más, sino copiar sin conocer el tamaño del destino. La regla es usar siempre funciones con límite (strncpy, snprintf, fgets) y medir.

Detalle de arquitectura. La figura muestra el marco clásico de x86-64, donde la dirección de retorno se guarda encima de las variables locales, en el mismo marco. En ARM64 (el contenedor del curso) el compilador la guarda al fondo de cada marco, así que el overflow de buf pisa la dirección de retorno del marco de quien llamó, y el salto ocurre cuando esa función retorna. El mecanismo y la lección son idénticos; cambia solo qué retorno se corrompe. En ambos casos la dirección a la que salta la CPU, 0x414141414141, son los bytes 0x41 de nuestras A.

Defensas y qué rompe cada una

Ninguna defensa sola arregla el problema; se apilan para que un error sea cada vez más difícil de convertir en control del programa.

  • Canario de pila (-fstack-protector). El compilador pone un valor aleatorio entre las variables y la dirección de retorno, y lo comprueba al salir. Rompe el camino “pisar la dirección de retorno con un overflow lineal”: no se puede llegar a ella sin pisar el canario. Es lo que acabamos de ver.

  • NX / W^X (pila y heap no ejecutables). El bit NX de la tabla de páginas marca la pila y el heap como no ejecutables: write xor execute, una página se escribe o se ejecuta, no ambas. Rompe el plan de meter instrucciones propias en el buffer y saltar a ellas, porque esa página ya no se puede ejecutar.

  • ASLR (address space layout randomization). El kernel coloca la pila, el heap, las bibliotecas y (con PIE) el código en direcciones distintas en cada ejecución. Rompe “saltar a una dirección fija”: el atacante ya no sabe dónde está lo que busca. aslr.c imprime una dirección de cada región; entre dos corridas casi todas cambian:

    $ ./aslr
    codigo  (main)    0xaaaac08d08d8
    datos   (global)  0xaaaac08f0010
    heap    (malloc)  0xaaaace4a72a0
    pila    (local)   0xffffeb3e2dac
    libc    (printf)  0xffffb7540590
    $ ./aslr
    codigo  (main)    0xaaaacc7508d8
    datos   (global)  0xaaaacc770010
    heap    (malloc)  0xaaab05c602a0
    pila    (local)   0xffffe366b15c
    libc    (printf)  0xffffa8780590

    Con setarch -R ./aslr (ASLR apagado) salen siempre iguales; así se estudia un binario sin la molestia del azar. También se ve en /proc/self/maps, que lista las regiones del proceso con sus direcciones.

  • PIE (position-independent executable). Para que ASLR mueva también el código y los datos del programa (no solo pila, heap y bibliotecas), el ejecutable debe compilarse como PIE. Sin PIE (-no-pie), aslr imprime siempre codigo (main) 0x4007e8 y datos (global) 0x420048, fijos, aunque ASLR esté activo (el heap, la pila y libc sí se mueven): son una dirección conocida a la que saltar. PIE es lo que cierra esa puerta.

  • CFI y mínimo privilegio. El control-flow integrity comprueba, en tiempo de ejecución, que cada salto indirecto y cada retorno vayan a un destino previsto; corta el reuso de trozos de código existente. Y, como vimos, un programa que suelta privilegios apenas termina lo que requería root limita el daño si lo comprometen. La defensa más barata sigue siendo la del principio: mínimo privilegio y poco código en la TCB.

Defensa Qué rompe Cómo se activa
Canario de pila Pisar la dirección de retorno linealmente -fstack-protector
NX / W^X Ejecutar código metido en la pila o el heap bit NX en la tabla de páginas
ASLR Saltar a una dirección fija randomize_va_space del kernel
PIE Direcciones fijas del propio programa -fPIE -pie
CFI Redirigir saltos y retornos a destinos no previstos -fcf-protection, -mbranch-protection

En xv6. La página de guarda que exec deja bajo la pila (con uvmclear, que le quita PTE_U) atrapa un caso distinto del de esta sección: cuando la pila crece demasiado y se sale de su espacio, el acceso cae en la página de guarda y provoca un fallo de página en vez de pisar silenciosamente otra zona. No frena un overflow dentro de un buffer, que escribe hacia direcciones altas y nunca toca esa página. Lo que xv6 claramente no tiene es ASLR: carga el programa siempre en las mismas direcciones, porque no se defiende contra un atacante local.

TOCTOU: una carrera entre el chequeo y el uso

No todas las vulnerabilidades son overflows. Una clase sutil son las TOCTOU (time of check to time of use): el programa comprueba una condición y luego actúa, pero entre el chequeo y el uso otra cosa cambia, y la acción ya no corresponde a lo que se comprobó.

El ejemplo clásico es un programa setuid root que, antes de abrir un archivo que el usuario le pide, quiere comprobar que ese usuario (no root) tiene permiso para leerlo. Lo escribe así:

if (access(ruta, R_OK) != 0)   // CHEQUEO: ¿puede leerlo el usuario real?
    return -1;
int fd = open(ruta, O_RDONLY); // USO: se abre como root

access usa el uid real (el del usuario), así que parece correcto. El problema es que access y open resuelven la ruta por separado. Si ruta es un enlace simbólico, el atacante lo hace apuntar a un archivo suyo para pasar el chequeo y, en la ventana entre las dos llamadas, lo reapunta a /etc/shadow. open, que corre como root, abre el segundo.

Animación: línea de tiempo con dos pistas, programa setuid arriba y atacante abajo. El programa hace access(f) y f apunta a mío.txt, que puede leer. En la ventana, el atacante ejecuta ln -sf secreto.txt f. Luego el programa hace open(f) y abre secreto.txt, que no debíaAnimación: línea de tiempo con dos pistas, programa setuid arriba y atacante abajo. El programa hace access(f) y f apunta a mío.txt, que puede leer. En la ventana, el atacante ejecuta ln -sf secreto.txt f. Luego el programa hace open(f) y abre secreto.txt, que no debía
El chequeo y el uso resuelven la ruta por separado. En la ventana entre ambos, el atacante reapunta el enlace y open abre un archivo que access nunca autorizó.

toctou.c lo hace visible sin setuid real: compara el inodo que vio access con el que abrió open. Con un bucle que alterna el enlace entre dos archivos, la carrera se gana una fracción de las veces:

$ make testhard           # un bucle alterna el enlace y corre ./toctou vuln link
  chequeo inodo=34297539  uso inodo=34297538  <-- TOCTOU   (lo imprime ./toctou)
  carreras ganadas: 122 de 300                              (lo cuenta el bucle)

La defensa no es “chequear más rápido”: la ventana nunca se cierra del todo. Es no separar el chequeo del uso. En lugar de access + open, el programa baja su euid al uid real y deja que open haga el chequeo con la identidad del usuario, en una sola llamada (es justo lo que recomienda la página man de access); añade O_NOFOLLOW para que un enlace en el último componente no cambie el destino, y a partir de ahí trabaja sobre el descriptor (fstat, fchown), que ya apunta a un archivo fijo. El descriptor es, otra vez, una capability: una vez obtenido, nadie cambia a qué objeto apunta. Es el mismo patrón de mínimo privilegio de ids.c: soltar root para la operación peligrosa.

Idea clave. Comprobar y actuar en dos pasos, sobre un nombre (una ruta), deja una ventana; hacerlo en un solo paso y luego operar sobre un handle (un descriptor) no. Por eso access es casi siempre la llamada equivocada: deja que open, con la identidad correcta, decida.

Otras clases de errores

El buffer overflow en la pila es el ejemplo didáctico, pero la familia es más amplia. Todas comparten la forma: una entrada que el programa no validó termina cambiando algo que no debía.

Clase Qué pasa Defensa típica
Use-after-free Se usa un puntero a memoria ya liberada, que el asignador reutilizó Poner el puntero en NULL; asignadores que endurecen; sanitizers
Heap overflow Una copia sin medir pisa metadatos del asignador o un objeto vecino Límites; asignadores con comprobaciones
Format string printf(entrada) deja que la entrada traiga %n/%x y lea o escriba memoria printf("%s", entrada); avisos del compilador
Integer overflow Un tamaño n*m da la vuelta y se reserva de menos, luego se escribe de más Comprobar antes de multiplicar; tipos con chequeo

Lectura. Para esta parte: la página man access(2) (advierte de forma explícita que usarla para decidir si abrir un archivo abre una carrera TOCTOU y recomienda no hacerlo), la documentación del kernel de Linux sobre el sysctl randomize_va_space (admin-guide/sysctl/kernel), y Tanenbaum y Bos, Modern Operating Systems, 5.ª ed., sec. 9.5 (Exploiting Software) y 9.8 (Operating System Hardening), como lectura complementaria.

Criptografía para el sistema operativo

El control de acceso funciona mientras el kernel tenga el control. Cuando los datos salen de su alcance, por la red o en un disco que alguien se lleva, hace falta criptografía.

Cifrado simétrico. Una misma clave K cifra y descifra (AES, ChaCha20). Es rápido; el problema es acordar K sin que nadie más la vea.

Cifrado de clave pública. Cada participante tiene una clave pública y una privada; lo cifrado con la pública de Beto solo lo descifra su privada (RSA, curvas elípticas). Resuelve el acuerdo, pero es mucho más lento, así que se usa un esquema híbrido: clave pública para acordar una clave de sesión, simétrico para los datos.

Arriba, cifrado simétrico: Ana cifra con K, el texto cifrado viaja y Beto descifra con la misma K; la pregunta es cómo acuerdan K sin que nadie más la vea. Abajo, clave pública: Ana cifra con la pública de Beto y Beto descifra con su privada, que solo él tiene; Beto publica la pública para todos, y Eva, que ve el texto cifrado y la pública, no puede descifrarArriba, cifrado simétrico: Ana cifra con K, el texto cifrado viaja y Beto descifra con la misma K; la pregunta es cómo acuerdan K sin que nadie más la vea. Abajo, clave pública: Ana cifra con la pública de Beto y Beto descifra con su privada, que solo él tiene; Beto publica la pública para todos, y Eva, que ve el texto cifrado y la pública, no puede descifrar
Con cifrado simétrico, las dos puntas comparten un secreto. Con clave pública, lo que se comparte es público y el secreto nunca sale de Beto.

Hashes criptográficos. Además de ser de un solo sentido, debe ser imposible encontrar dos entradas con el mismo hash.

Firmas digitales. Ana calcula H(m) y lo transforma con su clave privada: eso es la firma. Cualquiera con la clave pública de Ana puede verificar que la firma corresponde a H(m). Si alguien cambia un bit de m, la verificación falla (integridad), y solo quien tiene la privada pudo producirla (autenticidad). Se firma el hash y no el mensaje porque es corto y de tamaño fijo. Las firmas protegen los paquetes que instala apt, las actualizaciones del sistema y los módulos del kernel.

Cifrado de discos y arranque seguro

Si se roban la laptop, los permisos no sirven: el ladrón lee el disco desde otra computadora donde es root. El cifrado de disco (LUKS, FileVault, BitLocker) cifra cada bloque con una clave simétrica (fscrypt hace lo mismo por archivo o directorio). Esa clave se guarda a su vez cifrada con otra derivada de la contraseña del usuario, con un hash lento como los de la sección anterior, o sellada en un chip TPM. Protege una laptop robada y apagada. No protege un sistema encendido: ahí el kernel ya descifra los bloques para todos los procesos, y lo que decide son los permisos.

Un módulo TPM pequeño, con un chip y un cristal, conectado a un conector de la placa madre rotulado TPM
Un módulo TPM conectado a la placa madre. El chip guarda claves y solo las entrega si el arranque fue el esperado. Foto: FxJ, dominio público, Wikimedia Commons.

El arranque seguro (secure boot) protege la otra punta: que el kernel que arranca sea el legítimo. El firmware, en memoria de solo lectura, trae una clave pública y verifica la firma del bootloader; el bootloader verifica la del kernel; el kernel verifica la de sus módulos. Es una cadena de firmas que empieza en algo que no se puede modificar. Si alguien cambia el kernel en el disco, la verificación falla y la máquina no arranca.

Seguridad en sistemas distribuidos

En la red, un mensaje que dice “soy Ana” puede venir de cualquiera, y cualquiera en el camino puede leerlo, modificarlo o repetirlo.

Autenticar mensajes. Un MAC (message authentication code), como HMAC, es un “hash con clave”: si Eva cambia “pagar 10 a Beto” por “pagar 9000 a Eva”, sin K no puede calcular el MAC correcto. Cifrar no basta, porque se pueden alterar bits del texto cifrado a ciegas; por eso se usa cifrado autenticado (AEAD), como AES-GCM. Contra la repetición (replay) de un mensaje auténtico se autentican también números de secuencia o nonces.

Distribuir claves. Con claves simétricas, n usuarios necesitan n(n−1)/2 claves: 499 500 para 1000 usuarios. Con clave pública basta un par por usuario, pero ¿cómo sé que esta clave es de mi banco y no de Eva? Con un certificado: “la clave pública de banco.pe es P”, firmado por una autoridad de certificación (CA), cuya clave a su vez certifica una CA raíz. Las CAs raíz vienen con el sistema operativo (120 en /etc/ssl/certs/ca-certificates.crt en el contenedor); quien agregue una puede suplantar cualquier sitio, así que ese directorio es parte de la TCB.

Autenticación remota. Por la red, la contraseña solo debe viajar dentro de un canal cifrado como TLS. Mejor aún es no enviarla: en ssh con claves, el servidor manda un reto aleatorio, el cliente lo firma con su clave privada y el servidor verifica la firma con la clave pública registrada.

TLS. El protocolo que protege HTTPS junta todas estas piezas. En TLS 1.3, de forma simplificada:

  1. ClientHello y ServerHello intercambian valores aleatorios y las dos mitades de un Diffie-Hellman: ambos calculan la misma clave K sin enviarla. Desde ahí todo va cifrado.
  2. El servidor envía su certificado y una firma de toda la conversación con la clave privada del certificado.
  3. El cliente verifica la cadena hasta una CA raíz y la firma. Eva, en el medio, no tiene la clave privada del banco y no puede producirla.
  4. Ambos envían un Finished con un MAC del intercambio, y los datos viajan con AEAD usando K.

Cierre del curso

El curso tuvo tres piezas: virtualización (procesos, traps, planificación, tablas de páginas), concurrencia (hilos, locks, variables de condición, deadlock) y persistencia (discos, RAID, inodos, journaling). La seguridad no es una cuarta pieza aparte, sino otra forma de mirarlas: el aislamiento por páginas impide leer memoria ajena, una condición de carrera en el kernel puede ser una vulnerabilidad, los permisos viven en los inodos. Siempre aparece la misma estructura: un recurso físico, una abstracción limpia encima y un kernel que media cada acceso.

Resumen

  • Metas: confidencialidad, integridad y disponibilidad, frente a un modelo de amenazas explícito. El monitor de referencia media cada acceso; la TCB debe ser pequeña.
  • En xv6, la seguridad es aislamiento: el bit PTE_U, walkaddr, copyin y copyout, y la validación de argumentos en fetchaddr y argstr. No hay usuarios ni permisos.
  • Contraseñas: salt + hash lento (yescrypt en /etc/shadow). La salt obliga a atacar usuario por usuario; el hash lento encarece cada intento.
  • Una ACL es una columna de la matriz de acceso (fácil de revocar); una capability es una fila (fácil de delegar, difícil de revocar). Unix revisa la ACL en open y entrega un descriptor que funciona como capability.
  • La verificación de Unix elige una clase (owner, grupo u otros). setuid cambia el uid efectivo al owner del ejecutable: es útil y peligroso. Las ACLs POSIX agregan usuarios y grupos con nombre. MAC impone una política que el owner no puede cambiar.
  • Un buffer overflow pisa, con una copia sin medir, el canario, el frame pointer y la dirección de retorno contiguos en el marco de pila. Las defensas se apilan: canario (-fstack-protector), NX/W^X, ASLR, PIE y CFI; cada una rompe un paso del ataque, y debajo de todo está el mínimo privilegio.
  • TOCTOU: chequear y usar un nombre (ruta) en dos pasos deja una ventana que una carrera con un enlace simbólico aprovecha, sobre todo en programas setuid. Se evita bajando el privilegio y dejando que open chequee con la identidad del usuario, para luego trabajar sobre el descriptor.
  • TLS combina Diffie-Hellman, la firma del servidor sobre su certificado y cifrado autenticado.

Ejercicios

  1. ¿Por qué se guarda un hash con salt y no la contraseña o un hash simple? Usen los números de crack.c: ¿cuántas llamadas a crypt haría falta con 1000 usuarios y el mismo diccionario, con y sin salt, en el peor caso?
  2. ¿Qué es más fácil de revocar: una entrada en una ACL o una capability ya entregada? Relaciónenlo con este experimento: abran un archivo, quítenle todos los permisos con chmod 000 desde otra shell y sigan leyendo con el descriptor abierto. ¿Qué pasa?
  3. ¿Qué principio de diseño viola un programa setuid root que solo necesita abrir un archivo? Propongan dos alternativas y digan qué cambia en la TCB.
  4. Con umask 027, ¿con qué permisos queda un archivo creado con open(..., O_CREAT, 0666)? ¿Y un directorio creado con mkdir(..., 0777)? Verifíquenlo en el contenedor.
  5. Con getfacl y setfacl, den a Beto lectura sobre nota.txt y luego cambien la máscara con setfacl -m m::--- nota.txt. ¿Qué responde perms como Beto en la columna kernel? ¿Por qué?
  6. En timing.c, compilen con -O2. ¿Sigue habiendo diferencia de tiempos? ¿Podría el compilador “optimizar” la versión de tiempo constante y volverla insegura?
  7. En TLS, ¿qué pasaría si el servidor enviara su certificado pero no la firma de la conversación? Describan cómo Eva, en el medio, aprovecharía esa omisión.
  8. En smash.c, ¿por qué el valor que queda en la dirección de retorno tras el overflow es 0x4141… y no otro? Si la entrada fueran 30 bytes B en vez de A, ¿qué imprimiría el manejador de señal? (El código ASCII de B es 0x42.)
  9. Expliquen, para cada defensa de la tabla (canario, NX/W^X, ASLR, PIE), qué paso concreto del ataque de la figura buffer-overflow deja de funcionar. ¿Cuál de ellas no habría evitado el crash de smash.c sin canario, y por qué?
  10. Compilen aslr.c con y sin -no-pie y córranlo tres veces de cada forma en el contenedor (usen también setarch -R). ¿Qué direcciones cambian en cada caso y cuáles no? Relacionen lo que observan con qué randomiza ASLR y qué agrega PIE.
  11. En el TOCTOU de toctou.c, ¿por qué access seguido de open es vulnerable aunque access use el uid real? La versión seguro usa O_NOFOLLOW: ¿qué caso no cubre O_NOFOLLOW (pista: un enlace en un directorio intermedio de la ruta)? ¿Cómo adaptarían el patrón para un open(..., O_CREAT | O_TRUNC)?