Semana 16 — Seguridad

Objetivos

  • Definir las metas de seguridad (confidencialidad, integridad, disponibilidad) y los principios de diseño de Saltzer y Schroeder.
  • Explicar la autenticación con contraseñas (hash + sal o salt) y otros factores.
  • Comparar listas de control de acceso (ACLs) y capacidades (capabilities).
  • Explicar el buffer overflow en la pila y las defensas del SO (canario, NX/W^X, ASLR, PIE), y la carrera TOCTOU en programas setuid.
  • Conocer lo básico de criptografía aplicada y seguridad en sistemas distribuidos.

Sesión A — Introducción, autenticación y control de acceso

Lecturas:

Temas:

  • Modelo de amenazas, política vs. mecanismo, base de cómputo confiable (TCB).
  • Contraseñas: almacenamiento con hash y sal, ataques de diccionario. Autenticación multifactor.
  • ACLs vs. capacidades. Permisos Unix, setuid, sudo. Control de acceso obligatorio (MAC) y RBAC.

Sesión B — Explotación de software y endurecimiento

Lecturas:

  • Página man access(2) (advierte que usarla para decidir si abrir un archivo abre una carrera TOCTOU).
  • Linux kernel, documentación del sysctl randomize_va_space (admin-guide/sysctl/kernel).
  • Tanenbaum y Bos, Modern Operating Systems, 5.ª ed., sec. 9.5 (Exploiting Software) y 9.8 (Operating System Hardening).

Temas:

  • Buffer overflow en la pila: el marco de pila (buffer, canario, frame pointer guardado, dirección de retorno) y cómo una copia sin límites lo pisa.
  • Defensas y qué rompe cada una: canario (-fstack-protector), NX/W^X, ASLR, PIE, CFI, mínimo privilegio.
  • TOCTOU (access/open con un enlace simbólico) en programas setuid. Otras clases (use-after-free, format string, integer overflow), de lectura.

Este bloque continúa de setuid (“por qué es peligroso”) y del hilo de mínimo privilegio y TCB. Si el examen final se toma en esta sesión, el bloque se da en versión corta.

Criptografía y seguridad distribuida (lectura autónoma):

Laboratorio — Permisos y contraseñas

  1. Usuarios, grupos y permisos en el contenedor: chmod, chown, umask, un binario setuid y por qué es peligroso.
  2. ACLs POSIX con setfacl/getfacl.
  3. Hash de contraseñas con crypt(3) y sal; comparar con un hash sin sal frente a un diccionario pequeño.
  4. Endurecimiento: make testhard compila y corre smash (overflow con y sin canario), aslr (direcciones entre corridas, con y sin setarch -R y PIE) y toctou (ganar la carrera con un enlace simbólico).
  5. Repaso para el examen final.

Entregas

  • P5 entrega.
  • Bono entrega (fork con copy-on-write en xv6).

Preguntas de repaso

  1. ¿Por qué se guarda un hash con sal en lugar de la contraseña o un hash simple?
  2. ¿Qué es más fácil de revocar: una entrada en una ACL o una capacidad ya entregada? ¿Por qué?
  3. ¿Qué principio de diseño viola un programa setuid root que solo necesita abrir un archivo?

Código de la semana

Compilar dentro del contenedor del curso con make o gcc -Wall.

Ejemplos (6)Compilación (1)

Ejemplos

Los programas que usan los apuntes y la clase.

aslr.cDescargar aslr.c
// aslr.c: imprime una direccion de cada region del proceso (codigo, datos,
// heap, pila y una funcion de la biblioteca C). Con ASLR activo, casi todas
// cambian en cada corrida; con `setarch -R ./aslr` quedan fijas. Compilado sin
// PIE (-no-pie), codigo y datos quedan fijos aunque ASLR siga activo: eso es
// justo lo que agrega PIE.
//   uso: ./aslr
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>

static int global_var = 7;            // vive en el segmento de datos

int main(void) {
    int local_var = 0;                // vive en la pila
    int *heap = malloc(sizeof(int));  // vive en el heap

    printf("codigo  (main)    %p\n", (void *)main);
    printf("datos   (global)  %p\n", (void *)&global_var);
    printf("heap    (malloc)  %p\n", (void *)heap);
    printf("pila    (local)   %p\n", (void *)&local_var);
    printf("libc    (printf)  %p\n", (void *)printf);

    free(heap);
    return 0;
}
ids.cDescargar ids.c
// ids.c: imprime los IDs real y efectivo, intenta abrir un archivo,
// suelta los privilegios y vuelve a intentar.
// Se instala como setuid root para ver la diferencia entre uid y euid:
//   chown root ids && chmod u+s ids
//   uso: ./ids archivo
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

static void show(const char *when, const char *path) {
    printf("%-14s uid=%d euid=%d  ", when, (int)getuid(), (int)geteuid());
    int fd = open(path, O_RDONLY);
    if (fd < 0) {
        printf("open(%s): no se puede\n", path);
        return;
    }
    printf("open(%s): ok\n", path);
    close(fd);
}

int main(int argc, char *argv[]) {
    if (argc != 2) {
        fprintf(stderr, "uso: %s archivo\n", argv[0]);
        exit(1);
    }
    show("al empezar:", argv[1]);

    // Mínimo privilegio: si ya no se necesita, se suelta para siempre.
    // setuid(getuid()) siendo euid 0 cambia real, efectivo y guardado.
    if (setuid(getuid()) < 0) {
        perror("setuid");
        exit(1);
    }
    show("tras soltar:", argv[1]);

    // Ya no hay vuelta atrás: volver a euid 0 debe fallar.
    if (geteuid() != 0 && seteuid(0) < 0)
        printf("%-14s seteuid(0) falla: los privilegios se perdieron\n", "volver:");
    return 0;
}
perms.cDescargar perms.c
// perms.c: muestra los bits de permiso de un archivo y repite, paso a paso,
// la verificación que hace el kernel (owner, grupo, otros). Luego compara
// con lo que responde access(2).
//   uso: ./perms archivo...
#define _GNU_SOURCE
#include <stdio.h>
#include <sys/stat.h>
#include <unistd.h>

// Arma "rwxr-x---" a partir de st_mode, incluidos setuid/setgid/sticky.
static void mode_string(mode_t m, char *s) {
    const char *rwx = "rwxrwxrwx";
    for (int i = 0; i < 9; i++)
        s[i] = (m & (0400 >> i)) ? rwx[i] : '-';
    if (m & S_ISUID) s[2] = (m & S_IXUSR) ? 's' : 'S';
    if (m & S_ISGID) s[5] = (m & S_IXGRP) ? 's' : 'S';
    if (m & S_ISVTX) s[8] = (m & S_IXOTH) ? 't' : 'T';
    s[9] = '\0';
}

// La verificación de Unix: se elige UNA clase y solo se miran sus 3 bits.
// want es una máscara de 3 bits: 4 = leer, 2 = escribir, 1 = ejecutar.
static int unix_check(const struct stat *st, int want, const char **who) {
    uid_t uid = geteuid();
    if (uid == 0) {           // root se salta r y w; para x pide algún bit x
        *who = "root";
        return want != 1 || (st->st_mode & 0111) != 0;
    }
    int bits;
    if (uid == st->st_uid) {
        *who = "owner";
        bits = (st->st_mode >> 6) & 7;
    } else if (getegid() == st->st_gid || group_member(st->st_gid)) {
        *who = "grupo";
        bits = (st->st_mode >> 3) & 7;
    } else {
        *who = "otros";
        bits = st->st_mode & 7;
    }
    return (bits & want) == want;
}

int main(int argc, char *argv[]) {
    printf("uid=%d euid=%d gid=%d\n", (int)getuid(), (int)geteuid(), (int)getgid());
    for (int i = 1; i < argc; i++) {
        struct stat st;
        if (stat(argv[i], &st) < 0) {
            perror(argv[i]);
            continue;
        }
        char ms[10];
        mode_string(st.st_mode, ms);
        printf("%s  %04o %s  owner=%d grupo=%d\n", argv[i],
               (unsigned)(st.st_mode & 07777), ms, (int)st.st_uid, (int)st.st_gid);

        const char *names[] = {"leer", "escribir", "ejecutar"};
        int wants[] = {4, 2, 1};
        int modes[] = {R_OK, W_OK, X_OK};
        for (int k = 0; k < 3; k++) {
            const char *who;
            int ours = unix_check(&st, wants[k], &who);
            // eaccess usa los IDs efectivos, como open(2)
            int kern = eaccess(argv[i], modes[k]) == 0;
            printf("  %-8s clase=%-5s nuestro=%s kernel=%s\n", names[k], who,
                   ours ? "sí" : "no", kern ? "sí" : "no");
        }
    }
    return 0;
}
smash.cDescargar smash.c
// smash.c: un buffer overflow en la pila, de forma segura y local.
// vulnerable() copia con strcpy una entrada de tamaño arbitrario en un buffer
// de 16 bytes. Si la entrada es más larga, pisa lo que hay sobre buf en la
// pila, incluida una dirección de retorno guardada.
//
// Un manejador de señal informa a qué dirección saltó la CPU al retornar, para
// VER la dirección de retorno corrompida sin montar ningún ataque real:
//   - Sin canario, una entrada larga llena esa dirección con 0x41 ('A') y, al
//     retornar, la CPU salta a 0x4141… y falla.
//   - Con -fstack-protector, el canario lo detecta: "stack smashing detected".
//
// Detalle de arquitectura: en ARM64 (el contenedor del curso) el compilador
// guarda la dirección de retorno al fondo del marco de cada función, así que el
// overflow de buf pisa la dirección de retorno guardada en el marco de
// QUIEN llamó; el salto ocurre cuando esa función retorna. En x86-64 se pisa la
// de la propia función. El mecanismo y la lección son los mismos.
//
//   uso: ./smash ARCHIVO     (ARCHIVO tiene los bytes a copiar)
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <signal.h>
#include <ucontext.h>

static void onfault(int sig, siginfo_t *info, void *uc) {
    (void)info;
    ucontext_t *c = uc;
#if defined(__aarch64__)
    unsigned long pc = c->uc_mcontext.pc;
#elif defined(__x86_64__)
    unsigned long pc = c->uc_mcontext.gregs[REG_RIP];
#elif defined(__riscv)
    unsigned long pc = c->uc_mcontext.__gregs[0];
#else
    unsigned long pc = 0;
#endif
    fprintf(stderr, "[%s] la CPU intento ejecutar en 0x%lx\n",
            sig == SIGSEGV ? "SIGSEGV" : "SIGBUS", pc);
    _exit(139);
}

static void vulnerable(const char *src) {
    char buf[16];
    strcpy(buf, src);                 // sin medir: si src > 15 bytes, pisa mas alla de buf
    printf("copie %zu bytes en un buffer de 16\n", strlen(buf));
}

int main(int argc, char **argv) {
    setvbuf(stdout, NULL, _IONBF, 0); // sin buffer: lo impreso se ve aunque luego falle
    struct sigaction sa = {0};
    sa.sa_sigaction = onfault;
    sa.sa_flags = SA_SIGINFO;
    sigaction(SIGSEGV, &sa, NULL);
    sigaction(SIGBUS, &sa, NULL);

    if (argc != 2) {
        fprintf(stderr, "uso: %s ARCHIVO\n", argv[0]);
        return 1;
    }
    FILE *f = fopen(argv[1], "rb");
    if (!f) {
        perror("fopen");
        return 1;
    }
    char data[4096];
    size_t n = fread(data, 1, sizeof data - 1, f);
    data[n] = 0;
    fclose(f);

    vulnerable(data);
    // En x86-64 se corrompe la dirección de retorno de vulnerable, así que la
    // CPU ni llega aquí. En ARM64 se corrompe la de main: la ejecución SÍ llega
    // a este return, y el salto a 0x4141… ocurre al retornar de main.
    return 0;
}
timing.cDescargar timing.c
// timing.c: una comparación que sale apenas encuentra una diferencia
// filtra, por el tiempo, cuántos bytes del secreto acertó el atacante.
//   uso: ./timing
#include <stdio.h>
#include <string.h>
#include <time.h>

#define LEN 64
#define REPS 2000000

static const char secret[LEN + 1] =
    "s3cr3t-token-0123456789abcdef0123456789abcdef0123456789abcdef012";

// Insegura: termina en el primer byte distinto
static int naive_eq(const char *a, const char *b) {
    for (int i = 0; i < LEN; i++)
        if (a[i] != b[i])
            return 0;
    return 1;
}

// Tiempo constante: siempre recorre todo y acumula las diferencias
static int ct_eq(const char *a, const char *b) {
    unsigned char diff = 0;
    for (int i = 0; i < LEN; i++)
        diff |= (unsigned char)(a[i] ^ b[i]);
    return diff == 0;
}

static double ns_per_call(int (*eq)(const char *, const char *), const char *guess) {
    struct timespec t0, t1;
    volatile int sink = 0;
    clock_gettime(CLOCK_MONOTONIC, &t0);
    for (int r = 0; r < REPS; r++)
        sink += eq(secret, guess);
    clock_gettime(CLOCK_MONOTONIC, &t1);
    (void)sink;
    double ns = (t1.tv_sec - t0.tv_sec) * 1e9 + (t1.tv_nsec - t0.tv_nsec);
    return ns / REPS;
}

int main(void) {
    printf("aciertos   ingenua(ns)   tiempo-constante(ns)\n");
    int prefixes[] = {0, 16, 32, 48, 63};
    for (int k = 0; k < 5; k++) {
        char guess[LEN + 1];
        memset(guess, '?', LEN);
        guess[LEN] = '\0';
        memcpy(guess, secret, prefixes[k]);   // los primeros k bytes son correctos
        printf("%8d   %11.1f   %20.1f\n", prefixes[k],
               ns_per_call(naive_eq, guess), ns_per_call(ct_eq, guess));
    }
    return 0;
}
toctou.cDescargar toctou.c
// toctou.c: la condición de carrera time-of-check to time-of-use. La versión
// vulnerable comprueba el permiso con access() y luego abre con open(): son dos
// resoluciones distintas de la misma ruta. Si entre una y otra cambia un enlace
// simbólico, se termina usando un archivo distinto del que se autorizó.
//
// No hace falta setuid real para verlo: el programa compara el inodo que vio el
// chequeo con el que abrió el uso. Si no coinciden, la carrera se ganó.
//
//   ./toctou vuln   RUTA   -> con chequeo y uso separados (vulnerable)
//   ./toctou seguro RUTA   -> baja el privilegio y abre una sola vez (correcto)
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <sys/stat.h>

static int vulnerable(const char *path) {
    if (access(path, R_OK) != 0)             // CHEQUEO: ¿puede leerlo el usuario real?
        return -1;
    struct stat cs;
    if (stat(path, &cs) != 0)
        return -1;                           // inodo que vio el chequeo
    usleep(200);                             // ventana: el programa "trabaja" entremedio
    int fd = open(path, O_RDONLY);           // USO: vuelve a resolver la ruta
    if (fd < 0)
        return -1;
    struct stat us;
    fstat(fd, &us);                          // inodo que de verdad se abrió
    int raced = (cs.st_ino != us.st_ino);
    if (raced)
        printf("  chequeo inodo=%lu  uso inodo=%lu  <-- TOCTOU\n",
               (unsigned long)cs.st_ino, (unsigned long)us.st_ino);
    close(fd);
    return raced;
}

// Segura: en vez de chequear con access() y luego abrir (dos resoluciones), se
// baja el euid al uid real y se deja que open() haga el chequeo con la identidad
// del usuario, en una sola syscall: no hay ventana. O_NOFOLLOW evita además que
// un enlace simbólico en el último componente cambie el destino.
static int seguro(const char *path) {
    uid_t e = geteuid();
    if (seteuid(getuid()) != 0)              // actuar como el usuario real
        return -1;
    int fd = open(path, O_RDONLY | O_NOFOLLOW);  // el kernel chequea con el uid real
    int err = errno;
    if (seteuid(e) != 0) {                   // recuperar el privilegio
        if (fd >= 0) close(fd);
        return -1;
    }
    if (fd < 0) {
        errno = err;
        printf("  open rechazado (sin permiso del usuario real o enlace): no hay carrera\n");
        return -1;
    }
    struct stat us;
    fstat(fd, &us);                          // todo lo demás se decide sobre este fd
    usleep(200);
    close(fd);
    return 0;
}

int main(int argc, char **argv) {
    if (argc != 3) {
        fprintf(stderr, "uso: %s {vuln|seguro} RUTA\n", argv[0]);
        return 2;
    }
    return strcmp(argv[1], "seguro") == 0 ? seguro(argv[2]) : vulnerable(argv[2]);
}

Compilación

Cómo se compilan y ejecutan los ejemplos.

MakefileDescargar Makefile
# Semana 16: seguridad (permisos, setuid, ACLs, contraseñas).
#   make         compila todo
#   make test    crea usuarios y archivos en /tmp/w16 y corre cada programa
#                (pensado para el contenedor del curso, donde somos root)
#   make clean   borra los binarios
CFLAGS = -Wall -Wextra -g
PROGS  = perms ids hashpw crack timing
# Las soluciones aún no publicadas (code/.ocultos) no están en el repositorio:
# se omiten los programas sin fuente y sus líneas en las pruebas.
PROGS := $(foreach p,$(PROGS),$(if $(wildcard $(p).c),$(p)))
# Explotación y endurecimiento: cada binario se compila con banderas explícitas
# para que el resultado no dependa de los valores por defecto del compilador.
HARDEN = smash smash_ssp aslr aslr_nopie toctou
RUN    = $(if $(wildcard $(1).c),./$(1),@: sin $(1).c)
T      = /tmp/w16
# Ejecutar un comando como otro usuario, con sus grupos
AS     = setpriv --init-groups --reuid=$(1) --regid=$(1)
# Sin ASLR, para direcciones reproducibles
NOASLR = setarch -R

all: $(PROGS) $(HARDEN)

perms ids timing: %: %.c
	$(CC) $(CFLAGS) -o $@ $<

hashpw crack: %: %.c
	$(CC) $(CFLAGS) -o $@ $< -lcrypt

# smash: la misma fuente, una vez sin canario y otra con canario.
smash: smash.c
	$(CC) $(CFLAGS) -fno-stack-protector -no-pie -o $@ $<
smash_ssp: smash.c
	$(CC) $(CFLAGS) -fstack-protector-all -no-pie -o $@ $<

# aslr: con PIE (por defecto) y sin PIE, para ver qué agrega PIE.
aslr: aslr.c
	$(CC) $(CFLAGS) -fPIE -pie -o $@ $<
aslr_nopie: aslr.c
	$(CC) $(CFLAGS) -no-pie -o $@ $<

toctou: toctou.c
	$(CC) $(CFLAGS) -o $@ $<

# Usuarios ana y beto; grupo curso con ana. Tres archivos de prueba.
setup: perms ids
	rm -rf $(T)
	mkdir -p $(T)
	chmod 755 $(T)
	getent group curso >/dev/null || groupadd curso
	id ana  >/dev/null 2>&1 || useradd -M -G curso ana
	id beto >/dev/null 2>&1 || useradd -M beto
	echo "notas del curso" > $(T)/nota.txt
	chown ana:curso $(T)/nota.txt
	chmod 640 $(T)/nota.txt
	echo "solo root" > $(T)/secreto.txt
	chmod 600 $(T)/secreto.txt
	echo "raro" > $(T)/raro.txt
	chown ana:ana $(T)/raro.txt
	chmod 044 $(T)/raro.txt
	cp perms ids $(T)/
	chown root:root $(T)/ids
	chmod 4755 $(T)/ids

test: $(PROGS) setup
	@echo "--- permisos como root, ana y beto"
	./perms $(T)/nota.txt
	$(call AS,ana)  $(T)/perms $(T)/nota.txt $(T)/raro.txt
	$(call AS,beto) $(T)/perms $(T)/nota.txt
	@echo "--- ACL: dar lectura a beto sin tocar los bits"
	setfacl -m u:beto:r $(T)/nota.txt
	getfacl -c $(T)/nota.txt
	ls -l $(T)/nota.txt
	$(call AS,beto) $(T)/perms $(T)/nota.txt
	@echo "--- setuid root"
	ls -l $(T)/ids
	$(call AS,ana) $(T)/ids $(T)/secreto.txt
	@echo "--- contraseñas"
	$(call RUN,hashpw) alpaca
	$(call RUN,crack)
	@echo "--- comparación con fuga de tiempo"
	./timing
	@echo
	@$(MAKE) --no-print-directory testhard

# Explotación y endurecimiento, en el contenedor (seccomp=unconfined para setarch -R).
testhard: $(HARDEN)
	@mkdir -p $(T)
	@echo "=== buffer overflow en la pila"
	printf 'hola' > $(T)/in_ok
	head -c 30 /dev/zero | tr '\0' A > $(T)/in_overflow
	@echo "- sin canario, entrada corta:"
	$(NOASLR) ./smash $(T)/in_ok
	@echo "- sin canario, 30 bytes (pisa la dirección de retorno):"
	$(NOASLR) ./smash $(T)/in_overflow || true
	@echo "- con -fstack-protector, 30 bytes:"
	$(NOASLR) ./smash_ssp $(T)/in_overflow || true
	@echo "=== ASLR: direcciones en varias ejecuciones"
	@echo "- con PIE y ASLR (cambian entre corridas):"
	./aslr; ./aslr
	@echo "- con setarch -R (ASLR apagado: fijas):"
	$(NOASLR) ./aslr
	@echo "- sin PIE (-no-pie): código y datos fijos aunque ASLR siga activo:"
	./aslr_nopie
	@echo "=== TOCTOU: ganar la carrera con un enlace simbólico"
	@cd $(T); echo "decoy, lo pueden leer todos" > decoy.txt; echo "SECRETO" > target.txt; \
	  ( for i in $$(seq 1 60000); do ln -sfn decoy.txt link; ln -sfn target.txt link; done ) & \
	  atk=$$!; wins=0; \
	  for i in $$(seq 1 300); do \
	    r=$$($(CURDIR)/toctou vuln link 2>&1); \
	    case "$$r" in *TOCTOU*) wins=$$((wins+1)); [ $$wins -le 2 ] && echo "$$r";; esac; \
	  done; \
	  kill $$atk 2>/dev/null; wait 2>/dev/null; \
	  echo "  carreras ganadas: $$wins de 300"
	@echo "- versión segura (baja el euid al uid real + O_NOFOLLOW):"
	@cd $(T) && ln -sfn target.txt link && $(CURDIR)/toctou seguro link || true

clean:
	rm -f $(PROGS) $(HARDEN)

.PHONY: all setup test testhard clean