* notes / sistemas-operativos
Anillos de protección en el procesador: el origen del kernel mode
— Los anillos de privilegio en x86: cómo funciona un syscall, por qué el hipervisor es ring -1 y qué es el System Management Mode (ring -2).
La primera vez que vi la frase "Current Privilege Level 3" fue en el manual de Intel, buscando la causa de un segfault. Hasta ese momento, "modo usuario" y "modo kernel" eran para mí conceptos de libro. Ese día entendí que detrás de cada malloc y cada open() hay un mecanismo del propio procesador que decide, instrucción por instrucción, quién puede hacer qué.
Contexto: cuatro anillos, una jerarquía
La arquitectura x86 introduce, desde el 80286, cuatro anillos de protección numerados del 0 al 3. La convención es contraintuitiva: cuanto menor es el número, mayor es el privilegio. El anillo 0 puede ejecutar cualquier instrucción (incluidas las "privilegiadas" como LGDT, MOV CR3, HLT o INVLPG), mientras que el anillo 3, donde habitan tus procesos de usuario, sólo dispone de instrucciones inocuas. No obstante, en la práctica casi ningún sistema operativo moderno usa los anillos 1 y 2; Linux, Windows y los BSD se limitan a la dicotomía 0/3, juzgando que la complejidad añadida por los anillos intermedios no compensa.
Cabe destacar que la protección no se reduce al juego de instrucciones. El descriptor de segmento y, en el modelo moderno, las tablas de páginas llevan campos DPL (Descriptor Privilege Level) y bits de usuario/supervisor que el procesador coteja con el CPL actual en cada acceso. Si un proceso de anillo 3 intenta leer una página marcada como supervisor, la MMU lanza un page fault antes de que la instrucción complete. Por consiguiente, la separación entre kernel y usuario no descansa en buena voluntad sino en un control efectuado por hardware en cada ciclo.
El diagrama de los anillos
La frontera atravesada: anatomía de un syscall
El mecanismo histórico para cruzar la frontera era la interrupción software int 0x80, todavía presente en Linux por compatibilidad. Sin embargo, su coste (decenas de ciclos consultando la IDT, salvando estado y cambiando de pila) motivó a AMD e Intel a introducir las instrucciones especializadas SYSCALL/SYSRET (en modo largo) y SYSENTER/SYSEXIT (32 bits). Estas instrucciones leen el punto de entrada del kernel directamente de los MSR STAR, LSTAR y CSTAR, evitando el coste de la tabla de descriptores.
Veamos un read() minimalista en ensamblador x86_64, despojado de toda envoltura de libc:
; lee hasta 64 bytes desde stdin (fd 0) a buf
section .bss
buf: resb 64
section .text
global _start
_start:
mov rax, 0 ; syscall number: sys_read
mov rdi, 0 ; fd: stdin
mov rsi, buf ; buffer
mov rdx, 64 ; cantidad máxima
syscall ; <-- cruza a ring 0
mov rdi, rax ; valor de retorno como exit code
mov rax, 60 ; sys_exit
syscallEn el instante en que el procesador ejecuta syscall, ocurre lo siguiente:
- Se carga
RIPdesde el MSRLSTAR(la dirección del entry point del kernel,entry_SYSCALL_64en Linux). - Se carga
CScon el selector del kernel (CPL pasa de 3 a 0). RFLAGSse enmascara contraFMASKpara desactivar interrupciones y trampas.- El
RFLAGSoriginal se guarda enR11y elRIPoriginal enRCX.
A partir de aquí, el kernel ejecuta la rutina, valida los argumentos (especialmente el puntero buf, que debe pertenecer al espacio de direcciones del proceso para evitar la clase de bugs estilo "confused deputy"), completa la operación y emite sysret para devolver CPL=3 con el resultado en RAX.
El mismo viaje desde C, atravesando libc
#include <unistd.h>
int main(void) {
char buf[64];
ssize_t n = read(0, buf, sizeof buf); // (1) llamada a libc
return (int) n;
}La capa intermedia es invisible pero esencial. La función read() de glibc es un wrapper finísimo que coloca los argumentos en los registros correspondientes según la ABI System V AMD64 (rdi, rsi, rdx, r10, r8, r9), sustituye rcx por r10 (porque syscall clobberea rcx), emite syscall y traduce el valor negativo de retorno a errno. El kernel, por su parte, ni siquiera distingue si la petición proviene de C, Rust, Go o ensamblador puro: para él sólo existen registros con valores y un CPL que debe validar.
Más allá del anillo 0: virtualización y SMM
A finales de los 2000, AMD-V e Intel VT-x extendieron el modelo introduciendo lo que la prensa técnica bautizó como "ring -1": el modo VMX root. Un hipervisor de tipo 1 (KVM, Hyper-V, Xen) opera en este modo y arbitra la ejecución de uno o varios sistemas operativos invitados que creen estar en su ring 0. Cuando el invitado intenta una operación "sensible" (escribir CR3, ejecutar HLT, leer un MSR), el procesador efectúa un VMEXIT y devuelve el control al hipervisor, que decide cómo emular o permitir la acción. Asimismo, el invitado puede ceder voluntariamente la CPU mediante VMCALL.
Pero existe un nivel aún más profundo y bastante más inquietante: el System Management Mode, presente en x86 desde el 386SL y a menudo apodado "ring -2". Una vez que la BIOS instala el manejador SMI en la región SMRAM, ni el kernel ni el hipervisor pueden inspeccionarlo. Una interrupción SMI (disparada por gestión térmica, eventos del chipset o por software malicioso con acceso al puerto 0xB2) suspende cualquier cosa que estuviese ejecutándose y entrega el control a código firmado por el fabricante. Por consiguiente, todo el aparato de seguridad construido sobre Linux o Windows reposa, en última instancia, sobre la asunción de que el firmware es benigno. Esa es, dicho sea de paso, una de las grandes motivaciones detrás de iniciativas como Intel TDX y AMD SEV-SNP, que cifran la memoria del invitado incluso frente al hipervisor.
Lo que el modelo cambió en mi cabeza
Antes de digerir esta jerarquía pensaba en los sistemas operativos como un programa muy grande que decide cosas; después comprendí que el OS es, sobre todo, un proyecto de policía que delega coerción al hardware. Cuando una aplicación llama a fork(), no es que el kernel pueda hacer algo "más fuerte" que la app; es que el procesador, al verificar CPL=0, le permite manipular tablas de páginas que en ring 3 ni siquiera están direccionables.
Esa perspectiva cambia, asimismo, la forma de leer los CVE. Una escalada de privilegios local rara vez es "ejecutar código aleatorio en kernel"; suele ser convencer al kernel, que voluntariamente atiende una petición, de que escriba en una estructura privilegiada un valor controlado por el atacante. La frontera no se rompe: se engaña a quien la custodia.