Race Conditions y TOCTOU: El Timing como Arma
Las race conditions explotan ventanas temporales de microsegundos para subvertir la seguridad de sistemas operativos y aplicaciones. Desde TOCTOU hasta Dirty COW y Dirty Pipe, este artículo analiza cómo el timing se convierte en un arma ofensiva, por qué estas vulnerabilidades son tan difíciles de detectar y qué mecanismos de protección existen.
Cuando los microsegundos deciden la seguridad de un sistema
La mayoría de las vulnerabilidades son errores de lógica, de validación o de gestión de memoria. Las race conditions son algo diferente: son errores de timing. El código puede ser correcto en cada línea individual, pero vulnerable en la interacción temporal entre operaciones.
Una race condition ocurre cuando dos operaciones compiten por el mismo recurso y el resultado depende de cuál se ejecute primero. En un contexto de seguridad, esto se traduce en una ventana temporal (a veces de nanosegundos) donde un atacante puede intervenir para cambiar el estado del sistema entre dos operaciones que el programador asumió que serían atómicas.
Estas vulnerabilidades son especialmente temidas por tres razones. Primera: son difíciles de encontrar. Los tests tradicionales ejecutan código de forma secuencial y predecible, lo que elimina la condición que causa el bug. Segunda: son difíciles de reproducir. Un exploit de race condition puede necesitar miles de intentos antes de ganar la carrera. Tercera: cuando funcionan, el impacto suele ser escalada de privilegios a nivel de kernel.
TOCTOU: el patrón fundamental
TOCTOU (Time-of-Check Time-of-Use, pronunciado "tok-too") es el patrón de race condition más clásico y extendido. El nombre describe exactamente lo que ocurre:
- Time-of-Check (TOC): El programa verifica una condición (¿existe el archivo? ¿tiene los permisos correctos? ¿está el saldo disponible?).
- Ventana temporal: Un intervalo, por pequeño que sea, entre la verificación y el uso.
- Time-of-Use (TOU): El programa usa el recurso asumiendo que la condición verificada sigue siendo válida.
El problema: entre TOC y TOU, un atacante puede cambiar el estado del recurso.
Programa privilegiado Atacante
───────────────────── ──────────────────
1. access("/tmp/data", R_OK)
→ OK, el archivo es legible
2. rm /tmp/data
3. ln -s /etc/shadow /tmp/data
4. open("/tmp/data", O_RDONLY)
→ Abre /etc/shadow (!)
5. read(fd, buffer, size)
→ Lee el archivo de passwords
El diagrama muestra el problema fundamental. El programa verifica que /tmp/data es un archivo legible normal. Pero en la ventana entre access() y open(), el atacante reemplaza el archivo con un enlace simbólico a /etc/shadow. El programa abre el enlace y, al operar con privilegios elevados, lee el archivo de contraseñas del sistema.
Ejemplo en código: TOCTOU en sistema de archivos
Código vulnerable:
// Programa setuid (ejecuta con privilegios de root)
// VULNERABLE a race condition TOCTOU
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
void write_log(const char *filepath, const char *data) {
// TOC: verificar que el archivo pertenece al usuario
if (access(filepath, W_OK) == 0) {
// VENTANA: entre access() y open() el atacante
// puede reemplazar el archivo con un symlink
// TOU: abrir y escribir
int fd = open(filepath, O_WRONLY | O_APPEND);
write(fd, data, strlen(data));
close(fd);
}
}
Código seguro:
// Versión segura: eliminar la ventana TOCTOU
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/stat.h>
void write_log_safe(const char *filepath, const char *data) {
// Abrir primero con O_NOFOLLOW (no seguir symlinks)
int fd = open(filepath, O_WRONLY | O_APPEND | O_NOFOLLOW);
if (fd < 0) return;
// Verificar sobre el file descriptor abierto (no el path)
struct stat st;
fstat(fd, &st);
// Verificar que es un archivo regular y que pertenece al usuario real
if (S_ISREG(st.st_mode) && st.st_uid == getuid()) {
write(fd, data, strlen(data));
}
close(fd);
}
La diferencia clave: en la versión segura, la verificación y el uso operan sobre el mismo file descriptor. No hay ventana temporal porque fstat() opera sobre el archivo ya abierto, no sobre un path que podría cambiar. Además, O_NOFOLLOW rechaza abrir enlaces simbólicos directamente.
Symlink races en Unix
Los ataques de symlink race son la manifestación más práctica de TOCTOU. Son especialmente peligrosos en directorios compartidos como /tmp, donde cualquier usuario puede crear archivos.
El escenario clásico:
- Un programa privilegiado (root, setuid) crea un archivo temporal en
/tmpcon un nombre predecible. - El atacante predice o conoce el nombre del archivo temporal.
- Antes de que el programa cree el archivo, el atacante crea un symlink con ese nombre apuntando a un archivo sensible.
- El programa sigue el symlink y opera sobre el archivo sensible con privilegios elevados.
Ejemplo real: Python filelock. Una vulnerabilidad TOCTOU en la librería filelock de Python permitía que atacantes locales corrompieran o truncaran archivos arbitrarios. La librería verificaba si un archivo de lock existía con os.path.exists() y luego lo abría con os.open() usando O_TRUNC. En la ventana entre ambas operaciones, un atacante podía crear un symlink apuntando a cualquier archivo del usuario víctima. Al abrir con O_TRUNC, el archivo objetivo se truncaba a cero bytes. Esta vulnerabilidad afectaba a toda la cadena de dependencias: virtualenv, PyTorch, poetry y tox.
Ejemplo real: BSD mail. La utilidad mail de BSD 4.3 usaba mktemp() para generar nombres de archivos temporales predecibles. Un atacante podía anticipar el nombre, crear un symlink y lograr que mail escribiera contenido controlado en archivos arbitrarios. El ataque típicamente se completaba en menos de un minuto de intentos.
Dirty COW (CVE-2016-5195): la carrera que duró 9 años
Dirty COW es probablemente la race condition más famosa de la historia de Linux. Descubierta en octubre de 2016, existía en el kernel desde la versión 2.6.22 (publicada en 2007). Nueve años presente en prácticamente todos los sistemas Linux del planeta.
¿Cómo funciona Copy-on-Write?
Para entender Dirty COW, primero hay que entender el mecanismo COW (Copy-on-Write) del kernel Linux.
Cuando un proceso lee un archivo, el kernel mapea las páginas del archivo en la memoria virtual del proceso. Si múltiples procesos leen el mismo archivo, todos comparten las mismas páginas físicas de memoria (eficiencia). Cuando un proceso quiere escribir en una página compartida, el kernel crea una copia privada de esa página (la "copia en escritura") para que el proceso modifique su copia sin afectar a los demás.
Para archivos de solo lectura, el proceso puede solicitar un mapeado privado con mmap(MAP_PRIVATE). El kernel permite lectura pero, si el proceso intenta escribir, crea una copia COW privada. La escritura va a la copia, no al archivo original. El archivo en disco permanece intacto.
La carrera en mm/gup.c
La vulnerabilidad estaba en la función get_user_pages() del archivo mm/gup.c, que maneja el acceso a páginas de memoria de usuario. El problema era una race condition entre dos operaciones del kernel:
- Romper COW: El kernel detecta que el proceso quiere escribir en una página de solo lectura, crea una copia COW y actualiza los page table entries para apuntar a la copia.
- Escribir en la página: El kernel escribe los datos en la página (que debería ser la copia COW).
Un atacante podía usar dos hilos simultáneos:
- Hilo 1: Escribe repetidamente en
/proc/self/memen la dirección del mapeado del archivo de solo lectura. Esto fuerza al kernel a invocarget_user_pages()con el flagFOLL_WRITE. - Hilo 2: Llama repetidamente a
madvise(MADV_DONTNEED)sobre el mapeado. Esto le dice al kernel que descarte la página COW privada y vuelva a la página original del archivo.
La carrera ocurre así:
Hilo 1 (escritura) Hilo 2 (madvise)
──────────────────── ────────────────────
1. get_user_pages(FOLL_WRITE)
2. Kernel: página es read-only
→ crear copia COW
→ mapear copia en page table
3. madvise(MADV_DONTNEED)
→ descartar copia COW
→ restaurar mapping original
4. Kernel: reintentar
→ la página ya no es COW
→ quitar flag FOLL_WRITE (!)
5. Kernel: escribir en la página
→ escribe en la página ORIGINAL
→ modifica el archivo en disco
El paso crítico es el 4. Después de que madvise descarta la copia COW, el kernel reintenta el acceso pero, por el manejo incorrecto del flag FOLL_WRITE, pierde la noción de que la escritura requería una copia privada. El resultado: la escritura va directamente a la página del archivo original en el page cache.
Impacto de Dirty COW
Un usuario sin privilegios podía modificar cualquier archivo legible del sistema, incluyendo:
/etc/passwd: añadir una cuenta root o cambiar el UID de una cuenta existente.- Binarios setuid: modificar
/usr/bin/suo/usr/bin/passwdpara inyectar código que se ejecute como root. - Librerías compartidas: modificar libc para que cualquier programa ejecute código del atacante.
La explotación se confirmó in-the-wild. Phil Oester descubrió el exploit siendo usado activamente antes de que se publicara el parche.
Dirty Pipe (CVE-2022-0847): la heredera inesperada
Seis años después de Dirty COW, en marzo de 2022, el desarrollador Max Kellermann descubrió una nueva vulnerabilidad que permitía sobrescribir archivos de solo lectura en Linux. La llamó Dirty Pipe por analogía con Dirty COW, aunque el mecanismo técnico es completamente diferente.
El descubrimiento
Kellermann no buscaba vulnerabilidades. Estaba investigando por qué archivos de log comprimidos con gzip aparecían corruptos de forma intermitente. Después de meses de investigación, rastreó el problema hasta el mecanismo de splice() del kernel Linux y un flag de buffer de pipe que no se inicializaba correctamente.
El mecanismo técnico
Los pipes en Linux usan un ring buffer circular de páginas de memoria. Cada página tiene flags que indican al kernel cómo manejarla. Uno de esos flags es PIPE_BUF_FLAG_CAN_MERGE, que indica que se pueden añadir datos adicionales a una página que no está llena.
La vulnerabilidad:
- Crear un pipe.
- Llenarlo completamente para que se asignen todas las páginas del ring buffer.
- Vaciar el pipe (leer todos los datos). Los buffers se liberan pero los flags NO se reinicializan.
- Usar
splice()para mover datos desde un archivo de solo lectura al pipe.splice()es una operación zero-copy: en lugar de copiar datos, inserta una referencia a la página del page cache del archivo directamente en el pipe. - Escribir datos en el pipe. El kernel ve
PIPE_BUF_FLAG_CAN_MERGE(que no se reinicializó en el paso 3) y añade los datos a la página. Pero esa página es la página del page cache del archivo original.
El resultado: los datos escritos en el pipe se escriben directamente en el page cache del archivo, efectivamente sobrescribiendo el contenido del archivo de solo lectura.
Diferencias con Dirty COW
| Aspecto | Dirty COW | Dirty Pipe |
|---|---|---|
| Tipo | Race condition clásica | Fallo de inicialización |
| Determinismo | Probabilístico (requiere ganar la carrera) | Determinista (funciona al primer intento) |
| Kernels afectados | 2.6.22 a 4.8.3 (2007 a 2016) | 5.8 a 5.16.10 (2020 a 2022) |
| Mecanismo | COW + madvise race | splice() + flags no inicializados |
| Primer byte | Puede sobrescribir desde el byte 0 | No puede sobrescribir el primer byte |
| Extensión | Puede escribir más allá del tamaño original | No puede extender el archivo |
Dirty Pipe era, en cierto sentido, más peligroso que Dirty COW porque su explotación era determinista. No había que "ganar" ninguna carrera. El exploit funcionaba de forma fiable cada vez.
Double-fetch: race conditions dentro del kernel
Las vulnerabilidades double-fetch son una clase específica de race condition que ocurre cuando el kernel lee datos del espacio de usuario más de una vez.
El patrón
Kernel Atacante (espacio de usuario)
──────── ──────────────────────────────
1. Leer tamaño de userspace
size = copy_from_user(&size, user_ptr)
→ size = 10 bytes
2. Asignar buffer de 10 bytes
buf = kmalloc(10)
3. Modificar el valor en user_ptr
*user_ptr_size = 10000
4. Leer datos de userspace
copy_from_user(buf, user_data, *user_ptr)
→ copia 10000 bytes en buffer de 10
→ OVERFLOW EN KERNEL HEAP
El kernel lee el tamaño desde userspace, asigna un buffer basado en ese tamaño, y luego vuelve a leer el tamaño (o los datos) desde la misma dirección de userspace. En la ventana entre las dos lecturas, un hilo del proceso atacante modifica el valor. El kernel opera con datos inconsistentes.
Ejemplos documentados
Se han catalogado más de 91 vulnerabilidades double-fetch en la base de datos CVE a lo largo de 12 años. Algunos ejemplos notables:
- CVE-2016-6480: Driver
aacraiddel kernel Linux. Double-fetch en la operación ioctl donde el tamaño se lee dos veces, permitiendo heap overflow. - CVE-2016-5728: Subsistema
sgdel kernel. Un atacante podía causar escritura fuera de límites en memoria del kernel. - MS08-061: Double-fetch en el kernel de Windows que permitía escalada de privilegios local.
La solución canónica para double-fetch es simple en concepto: copiar todos los datos de userspace a kernel space una sola vez, al inicio de la syscall, y operar exclusivamente sobre la copia. Pero en la práctica, las bases de código de kernels son enormes y encontrar todos los double-fetches requiere análisis especializado.
Race conditions en aplicaciones web
Las race conditions no son exclusivas de kernels y sistemas operativos. Las aplicaciones web también son vulnerables, especialmente en operaciones que involucran verificar y luego actuar.
Ejemplo: doble gasto en transferencias
# VULNERABLE: race condition en transferencia bancaria
async def transfer(sender_id, receiver_id, amount):
# TOC: verificar saldo
sender = await db.get_account(sender_id)
if sender.balance >= amount: # Verificación
# VENTANA: otro request concurrente puede verificar
# el mismo saldo antes de que se debite
# TOU: ejecutar transferencia
await db.update_balance(sender_id, sender.balance - amount)
await db.update_balance(receiver_id, receiver.balance + amount)
Si un atacante envía dos peticiones de transferencia simultáneas por el saldo completo, ambas pueden pasar la verificación antes de que ninguna debite el saldo. Resultado: el doble del dinero se transfiere.
Código seguro:
# SEGURO: operación atómica con transacción serializable
async def transfer_safe(sender_id, receiver_id, amount):
async with db.transaction(isolation_level="SERIALIZABLE"):
sender = await db.get_account_for_update(sender_id) # SELECT FOR UPDATE
if sender.balance >= amount:
await db.update_balance(sender_id, sender.balance - amount)
await db.update_balance(receiver_id, receiver.balance + amount)
else:
raise InsufficientFunds()
SELECT FOR UPDATE bloquea la fila hasta que la transacción finalice. La segunda petición espera a que la primera complete. Si el saldo ya se debitó, la verificación falla.
Ejemplo: registros duplicados (race condition en signup)
# VULNERABLE: verificar username y luego crear usuario
async def register(username, password):
exists = await db.user_exists(username)
if not exists:
# VENTANA: otro request puede verificar el mismo username
await db.create_user(username, hash(password))
Solución: restricción UNIQUE en la base de datos y manejar la excepción de duplicado, o usar INSERT ... ON CONFLICT DO NOTHING.
Fuzzing y detección de race conditions
Encontrar race conditions con análisis manual es extremadamente difícil. Las herramientas especializadas son esenciales.
ThreadSanitizer (TSan)
ThreadSanitizer es un detector de race conditions en tiempo de ejecución, integrado en GCC y Clang. Instrumenta cada acceso a memoria y detecta accesos sin protección desde múltiples hilos:
# Compilar con ThreadSanitizer
gcc -fsanitize=thread -g -O1 programa.c -o programa_tsan
./programa_tsan
TSan reporta la ubicación exacta de los dos accesos concurrentes que causan la carrera, los hilos involucrados y los stack traces.
Fuzzers especializados
- Razzer: Fuzzer de kernel que busca race conditions manipulando la planificación de hilos del kernel para maximizar la probabilidad de interleaving peligroso.
- krace: Detecta race conditions en sistemas de archivos del kernel Linux.
- DEADLINE: Detector estático de vulnerabilidades double-fetch en código de kernel, basado en análisis de flujo de datos.
Análisis estático
Herramientas como Coverity y CodeQL pueden detectar patrones TOCTOU en código fuente:
// Patrón que un analizador estático puede detectar:
// 1. Llamada a stat()/access()/lstat() sobre un path
// 2. Seguida de open()/fopen()/chmod() sobre el mismo path
// → Posible TOCTOU
Pero el análisis estático genera falsos positivos porque no todo par check/use es explotable. El contexto importa: si el directorio donde reside el archivo solo es escribible por root, el TOCTOU no es explotable por usuarios normales.
Mecanismos de prevención
Operaciones atómicas en sistema de archivos
La regla de oro: no verificar un path y luego operar sobre ese path. Operar directamente y manejar los errores.
// VULNERABLE
if (access("/tmp/file", W_OK) == 0) {
fd = open("/tmp/file", O_WRONLY);
}
// SEGURO
fd = open("/tmp/file", O_WRONLY | O_NOFOLLOW);
if (fd >= 0) {
// Verificar con fstat sobre el fd abierto
struct stat st;
fstat(fd, &st);
if (st.st_uid == expected_uid) {
// Operar sobre fd
}
}
Mutex y locks para memoria compartida
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
int shared_counter = 0;
void increment_safe() {
pthread_mutex_lock(&lock);
shared_counter++; // Sección crítica protegida
pthread_mutex_unlock(&lock);
}
El mutex garantiza que solo un hilo accede a shared_counter a la vez. Si otro hilo intenta adquirir el lock mientras está tomado, espera.
Directorios seguros para archivos temporales
// Usar mkstemp() en lugar de mktemp()
// mkstemp() crea Y abre el archivo atómicamente
char template[] = "/tmp/myapp.XXXXXX";
int fd = mkstemp(template);
// fd ya está abierto, no hay ventana TOCTOU
La diferencia: mktemp() genera un nombre y el programa lo abre después (ventana TOCTOU). mkstemp() genera el nombre Y abre el archivo en una sola operación atómica.
Prevención de double-fetch en kernel
// VULNERABLE: dos lecturas de userspace
if (copy_from_user(&header, user_ptr, sizeof(header)))
return -EFAULT;
buf = kmalloc(header.size);
// Segunda lectura: el atacante pudo cambiar los datos
if (copy_from_user(buf, user_ptr + sizeof(header), header.size))
return -EFAULT;
// SEGURO: una sola lectura, operar sobre copia en kernel space
char *kbuf = kmalloc(total_size);
if (copy_from_user(kbuf, user_ptr, total_size))
return -EFAULT;
struct header *h = (struct header *)kbuf;
char *data = kbuf + sizeof(*h);
// Toda la operación usa datos copiados una vez
Transacciones y locks en bases de datos
Para aplicaciones web, las transacciones con nivel de aislamiento adecuado previenen race conditions en lógica de negocio:
| Nivel de aislamiento | Protege contra |
|---|---|
| READ COMMITTED | Dirty reads |
| REPEATABLE READ | Non-repeatable reads |
| SERIALIZABLE | Phantom reads y race conditions de lógica |
SELECT ... FOR UPDATE es la herramienta más directa para prevenir race conditions en filas específicas.
Conexión con MITRE ATT&CK
Las race conditions se mapean a T1068 (Exploitation for Privilege Escalation) en MITRE ATT&CK. En el contexto del framework:
- Acceso inicial: El atacante necesita acceso local al sistema (usuario sin privilegios).
- Escalada de privilegios (T1068): La race condition permite escribir en archivos de root, modificar binarios setuid o corromper memoria del kernel.
- Persistencia (T1547): Modificar archivos de inicio o binarios del sistema.
Dirty COW y Dirty Pipe fueron añadidas al catálogo CISA KEV (Known Exploited Vulnerabilities), confirmando su explotación activa in-the-wild.
¿Por qué persisten?
Las race conditions persisten por razones fundamentales:
1. La concurrencia es inherente a los sistemas modernos. Múltiples cores, múltiples hilos, múltiples procesos, I/O asíncrono: los sistemas modernos están diseñados para ejecutar operaciones en paralelo. Cada punto de concurrencia es un posible punto de carrera.
2. El modelo mental secuencial de los programadores. Los humanos pensamos en secuencias: primero verifico, luego actúo. Ese modelo mental mapea directamente a patrones TOCTOU. Pensar en las interleaving posibles de operaciones concurrentes requiere un esfuerzo cognitivo significativo.
3. Las bases de código de kernels son enormes. El kernel Linux tiene más de 30 millones de líneas de código. Auditar cada interacción concurrente es prácticamente imposible.
4. Los tests son insuficientes. Un test de race condition es probabilístico. Puede pasar 10.000 veces y fallar en producción bajo carga. La ventana temporal puede ser de nanosegundos, demasiado estrecha para reproducir de forma consistente.
5. Las correcciones introducen complejidad. Añadir locks previene la carrera pero puede introducir deadlocks, reducir el rendimiento o crear nuevas ventanas de carrera en otros puntos.
Conclusión
Las race conditions representan una clase de vulnerabilidad donde el enemigo no es un input malicioso ni un buffer sin límites, sino el tiempo mismo. La ventana entre verificar y actuar, entre leer y escribir, entre comprobar un permiso y ejercerlo: esos microsegundos son el campo de batalla.
Dirty COW demostró que una carrera en nueve líneas de código del kernel podía afectar a todos los sistemas Linux del mundo durante nueve años. Dirty Pipe mostró que los mecanismos de I/O del kernel, optimizados durante décadas para rendimiento, pueden contener errores sutiles que otorgan acceso de escritura donde no debería haberlo.
La lección central: en cualquier sistema concurrente, la pregunta no es si existen race conditions, sino cuáles son explotables. Y la respuesta, como demuestra la historia, suele sorprender incluso a los desarrolladores del kernel.
Técnicas MITRE ATT&CK referenciadas
Preguntas frecuentes
Artículos relacionados
Este contenido tiene fines exclusivamente educativos y de investigación en ciberseguridad defensiva. No se proporcionan binarios maliciosos ni payloads ejecutables. El uso indebido de esta información es responsabilidad exclusiva del usuario. Leer disclaimer completo.