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:
- Cap. 52 Diálogo sobre seguridad
- Cap. 53 Introduction to Operating System Security
- Cap. 54 Authentication
- Cap. 55 Access Control
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/opencon un enlace simbólico) en programassetuid. 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):
- Cap. 56 Cryptography
- Cap. 57 Distributed System Security
- Cifrado simétrico y asimétrico, hashes, firmas; cifrado de discos; certificados y TLS.
Laboratorio — Permisos y contraseñas
- Usuarios, grupos y permisos en el contenedor:
chmod,chown,umask, un binariosetuidy por qué es peligroso. - ACLs POSIX con
setfacl/getfacl. - Hash de contraseñas con
crypt(3)y sal; comparar con un hash sin sal frente a un diccionario pequeño. - Endurecimiento:
make testhardcompila y corresmash(overflow con y sin canario),aslr(direcciones entre corridas, con y sinsetarch -Ry PIE) ytoctou(ganar la carrera con un enlace simbólico). - Repaso para el examen final.
Entregas
- P5 entrega.
- Bono entrega (
forkcon copy-on-write en xv6).
Preguntas de repaso
- ¿Por qué se guarda un hash con sal en lugar de la contraseña o un hash simple?
- ¿Qué es más fácil de revocar: una entrada en una ACL o una capacidad ya entregada? ¿Por qué?
- ¿Qué principio de diseño viola un programa
setuid rootque solo necesita abrir un archivo?
Código de la semana
Compilar dentro del contenedor del curso con make o gcc -Wall.
Ejemplos
Los programas que usan los apuntes y la clase.
// 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.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.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.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.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.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.
# 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