Comprobar que un archivo no existe parece suficiente para crearlo sin sobrescribir nada. Consultar el stock antes de descontar una unidad también parece razonable. El problema aparece cuando otra operación cambia ese estado antes de que terminemos.
Una validación puede ser correcta cuando se ejecuta y dejar de serlo unos instantes después. La seguridad depende tanto de qué comprobamos como de cuándo y bajo qué garantías utilizamos el resultado.
En noviembre de 2026, CISA incorporó CVE-2026-119977, una vulnerabilidad de WordPress Core, a su catálogo de vulnerabilidades explotadas conocidas. El caso permite estudiar este problema sin reducirlo a un producto: también afecta a archivos, reservas, permisos y procesos de actualización. Primero entenderemos el mecanismo y después lo observaremos con un ejemplo local en Node.js. Catálogo KEV oficial de CISA.
Qué es una race condition
Una race condition, o condición de carrera, aparece cuando el resultado de varias operaciones depende de cómo se intercalan y el programa no controla adecuadamente ese orden.
Imagina dos solicitudes que quieren reservar la última unidad de un producto. Ambas leen stock = 1, ambas deciden que pueden continuar y ambas confirman una reserva. Cada comprobación individual parece válida; el conjunto viola la regla de no vender más unidades de las disponibles.
No toda carrera constituye una vulnerabilidad. Puede ser un error funcional. Se vuelve relevante para la seguridad cuando permite incumplir una garantía: gastar dos veces un saldo, saltarse un límite, modificar datos ajenos o utilizar un archivo diferente del autorizado.
Concurrencia no exige ejecutar dos instrucciones exactamente al mismo tiempo. Basta con intercalar pasos de operaciones todavía pendientes. En Node.js, un await permite continuar otro trabajo mientras se completa una operación asíncrona; además, otros procesos pueden modificar el sistema de archivos o la base de datos. Que JavaScript utilice un event loop no convierte una secuencia de operaciones de I/O en una unidad indivisible. Modelo de ejecución de Node.js.
TOCTOU: comprobar un estado que ya no existe
TOCTOU significa Time-of-check to Time-of-use: tiempo entre la comprobación y el uso. Es un tipo de carrera en el que cambia una propiedad del recurso y la aplicación actúa utilizando una comprobación que quedó obsoleta. Definición de CWE-367.
No se pudo mostrar el diagrama. Puedes consultar su definición.
sequenceDiagram
participant A as Operación A
participant R as Archivo compartido
participant B as Operación B
A->>R: Comprobar si existe
R-->>A: No existe
B->>R: Crear el archivo
Note over A,R: La comprobación de A ya no describe el estado actual
A->>R: Escribir asumiendo que no existe
R-->>A: Archivo de B sobrescrito
El fallo está en asumir que una observación sigue siendo válida sin proteger la transición. Comprobar otra vez reduce una ventana, pero deja otra entre la nueva comprobación y la acción. Hacer el código más rápido tampoco elimina esa posibilidad.
En una ruta de archivo puede cambiar incluso la identidad del recurso: el mismo nombre puede pasar a referirse a otro archivo. En una API puede cambiar el propietario de un registro o su estado de aprobación. Necesitamos proteger la propiedad concreta de la que depende la operación.
WordPress: una ventana de actualización también es superficie de ataque
El registro de CVE-2026-119977 describe ejecución remota de código sin autenticación durante actualizaciones completas de WordPress Core, asociada a rutas temporales predecibles y accesibles por web. La entrada del KEV tiene fecha 3 de noviembre de 2026; su inclusión indica explotación conocida, no que todos los sitios vulnerables hayan sido comprometidos. Registro CVE y feed oficial de CISA.
El aviso del proyecto acota las condiciones: se extraía un paquete completo a un directorio predecible dentro de la carpeta de actualización servida por web, y un atacante debía ganar la carrera durante esa extracción. No equivale a una ejecución remota disponible permanentemente con solo conocer la versión. Importaban el proceso de actualización, la exposición del área temporal y el momento de acceso. Aviso de seguridad de WordPress.
WordPress publicó 7.1.4 el 28 de octubre de 2026. El aviso también enumera correcciones para ramas anteriores, como 7.0.8 y 6.9.11; por eso conviene consultar los intervalos afectados y la corrección de cada rama, en lugar de tratar cualquier número inferior a 7.1.4 como una versión sin parche. Publicación de WordPress 7.1.4.
Qué cambió el parche
El commit oficial de la corrección modifica dos archivos del actualizador:
- En
class-wp-upgrader.php, añade un componente aleatorio al nombre del directorio de trabajo. - En ese mismo archivo, crea y conserva un
index.phppara evitar que el listado del directorio revele esos nombres. - En
update-core.php, excluyewp-config.phpde los archivos que se copian desde el directorio de trabajo.
Son cambios concretos sobre la previsibilidad, el descubrimiento del área temporal y la propagación de configuración. El parche no convierte toda la actualización en una transacción atómica, ni establece que añadir un nombre aleatorio resuelva cualquier TOCTOU.
La enseñanza de diseño es que un proceso de actualización también maneja estados intermedios: archivos extraídos pero todavía no instalados, permisos temporales y rutas que quizá no deberían aceptar peticiones. Para un actualizador propio, conviene preparar el contenido en un área controlada, restringir quién puede acceder y modificarla, y publicar únicamente el estado validado. Son recomendaciones de arquitectura; no sustituyen la corrección oficial de WordPress.
Atomicidad, locks, transacciones y validaciones
Antes de elegir una técnica, define una invariante: una regla que debe cumplirse aunque varias operaciones compitan. Por ejemplo, «no sobrescribir un archivo existente» o «el stock nunca baja de cero».
| Mecanismo | Qué aporta | Qué debes delimitar |
|---|---|---|
| Validación | Decide si los datos o el estado cumplen una condición | Una observación no reserva el recurso |
| Operación atómica | Ejecuta una transición sin un estado intermedio observable dentro de su garantía | La garantía corresponde a esa operación, no a todo el flujo |
| Lock o bloqueo | Impide operaciones incompatibles mientras se mantiene | Debe cubrir comprobación y uso, y coordinar a los participantes pertinentes |
| Transacción | Agrupa cambios con commit o rollback | La atomicidad de la transacción y su aislamiento son propiedades distintas |
Un mutex en memoria puede coordinar tareas de un proceso, pero no a otras réplicas de la API. Un bloqueo que se adquiere después de leer el estado puede proteger una decisión que ya nació obsoleta.
En PostgreSQL, SELECT ... FOR UPDATE bloquea las filas seleccionadas hasta que termina la transacción. Eso permite leer y modificar esas filas bajo una coordinación explícita, pero no significa que bloquee todas las filas futuras que podrían cumplir una condición. Los bloqueos también pueden producir esperas y deadlocks: operaciones que se esperan mutuamente. Mantener transacciones breves y un orden consistente de adquisición ayuda a reducirlos. Bloqueos explícitos de PostgreSQL.
Poner BEGIN y COMMIT alrededor de dos consultas no demuestra por sí solo que la regla de negocio esté protegida. Hay que elegir el aislamiento y los bloqueos según esa regla. El nivel SERIALIZABLE puede rechazar una transacción cuando no puede preservar un resultado equivalente a una ejecución serial; la aplicación debe estar preparada para reintentar la transacción completa. Aislamiento de transacciones.
Un ejemplo local en Node.js
Vamos a implementar una operación pequeña: crear un archivo únicamente si todavía no existe. Usaremos una carpeta temporal propia y contenido ficticio. Este laboratorio no reproduce el fallo de WordPress, no ejecuta PHP y no abre ningún servidor.
Necesitas Node.js 22 o superior. Crea una carpeta vacía con dos archivos: archivos.mjs y demo.mjs. No requiere paquetes ni instalación de dependencias.
Separar la comprobación de la creación
Guarda lo siguiente en archivos.mjs. La primera función es intencionalmente vulnerable y la segunda será la alternativa:
import { access, open, writeFile } from 'node:fs/promises';
// Vulnerable: solo para demostrar la comprobación obsoleta.
export async function crearInseguro(ruta, contenido, despuesDeComprobar) {
try {
await access(ruta);
return false;
} catch (error) {
if (error.code !== 'ENOENT') {
throw error;
}
}
// El laboratorio inserta aquí la operación competidora.
await despuesDeComprobar();
await writeFile(ruta, contenido, { mode: 0o600 });
return true;
}
export async function crearExclusivo(ruta, contenido) {
let archivo;
try {
archivo = await open(ruta, 'wx', 0o600);
} catch (error) {
if (error.code === 'EEXIST') {
return false;
}
throw error;
}
try {
await archivo.writeFile(contenido, 'utf8');
return true;
} finally {
await archivo.close();
}
}En crearInseguro, access() comprueba la existencia y writeFile() actúa después. La función despuesDeComprobar es un punto de sincronización del laboratorio: nos permitirá introducir una creación competidora exactamente en ese intervalo. No es una espera aleatoria ni una función necesaria en producción.
La alternativa utiliza open() con wx: solicita una creación exclusiva y trata EEXIST como conflicto esperado. Los demás errores se propagan. La documentación de Node.js desaconseja comprobar acceso antes de abrir o escribir precisamente por esta carrera. API de archivos de Node.js.
Observar el fallo y comprobar la alternativa
Guarda este segundo archivo como demo.mjs:
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { crearInseguro, crearExclusivo } from './archivos.mjs';
const directorio = await mkdtemp(join(tmpdir(), 'toctou-demo-'));
try {
const vulnerable = join(directorio, 'vulnerable.txt');
await crearInseguro(vulnerable, 'contenido de A', async () => {
await writeFile(vulnerable, 'contenido de B', { flag: 'wx' });
});
assert.equal(await readFile(vulnerable, 'utf8'), 'contenido de A');
console.log('Vulnerable: A sobrescribió el archivo que B creó después del check.');
const existente = join(directorio, 'existente.txt');
await writeFile(existente, 'contenido de B', { flag: 'wx' });
assert.equal(await crearExclusivo(existente, 'contenido de A'), false);
assert.equal(await readFile(existente, 'utf8'), 'contenido de B');
console.log('Exclusivo: el archivo existente de B se conservó.');
const compartido = join(directorio, 'compartido.txt');
const contenidos = ['contenido de A', 'contenido de B'];
// Esperamos todas las operaciones antes de limpiar, incluso si una falla.
const resultados = await Promise.allSettled(
contenidos.map((contenido) => crearExclusivo(compartido, contenido)),
);
assert.ok(resultados.every((resultado) => resultado.status === 'fulfilled'));
const ganadores = resultados.filter((resultado) => resultado.value === true);
assert.equal(ganadores.length, 1);
const indiceGanador = resultados.findIndex((resultado) => resultado.value === true);
assert.equal(await readFile(compartido, 'utf8'), contenidos[indiceGanador]);
console.log('Concurrente: una creación aceptada y una rechazada.');
} finally {
await rm(directorio, { recursive: true, force: true });
}Ejecútalo:
node demo.mjsSalida verificada con Node.js 24:
Vulnerable: A sobrescribió el archivo que B creó después del check.
Exclusivo: el archivo existente de B se conservó.
Concurrente: una creación aceptada y una rechazada.La primera prueba fuerza un intercalado concreto sobre archivos reales: A comprueba, B crea y A escribe. La segunda comprueba que la alternativa conserva un archivo existente. La tercera inicia dos creaciones concurrentes y verifica una propiedad, sin asumir quién gana: exactamente una se acepta y el contenido final pertenece a esa operación.
Promise.allSettled() permite esperar a que ambas terminen antes de borrar la carpeta temporal, incluso si alguna falla. Las aserciones detectan un resultado incorrecto y el finally elimina los archivos de prueba. Para retirar el laboratorio, elimina los dos archivos .mjs de la carpeta que creaste.
Qué garantiza esta alternativa y qué no
wx protege la creación exclusiva del nombre; no garantiza que otros lectores vean el contenido completo de una sola vez, ni asegura su durabilidad ante una caída. Si la escritura falla después de crear el archivo, puede quedar vacío o incompleto.
El ejemplo supone un directorio controlado y un sistema de archivos local. No acepta rutas del usuario ni protege directorios padre modificables por un atacante. Node.js también advierte que la exclusividad puede no funcionar en sistemas de archivos de red. Semántica de los flags de archivos.
Si necesitas publicar un documento completo, diseña por separado su preparación y su publicación. Una estrategia habitual es escribir en un temporal privado y después renombrarlo bajo garantías conocidas del sistema de archivos. Un renombrado que permite reemplazar el destino sirve para una semántica de sustitución, pero no demuestra «crear solo si no existe». Visibilidad atómica, exclusividad y persistencia son requisitos diferentes.
El mismo problema en bases de datos y APIs
Descontar stock bajo una condición
En PostgreSQL, en vez de leer el stock y decidir en JavaScript si ejecutar otro cambio, puedes expresar una transición condicional. Este fragmento supone una tabla inventario con id único y stock entero no nulo:
UPDATE inventario
SET stock = stock - 1
WHERE id = $1
AND stock > 0
RETURNING id, stock;$1 representa un parámetro enviado mediante el driver, no una interpolación de texto. Si no retorna filas, la operación no descontó ninguna unidad. En READ COMMITTED, PostgreSQL vuelve a evaluar el WHERE sobre la fila actualizada por una operación competidora antes de aplicar su propio cambio. Comportamiento de UPDATE en PostgreSQL.
Esto protege el descuento sobre esa fila si todas las rutas respetan la regla; una restricción CHECK (stock >= 0) puede reforzarla. Si también debes crear una reserva, ambos cambios necesitan coordinarse en una transacción. El SQL ilustra el diseño; el laboratorio ejecutado en este artículo es el de archivos.
Para impedir duplicados, una restricción única suele ser más fiable que «consultar si existe y luego insertar». Los índices únicos de PostgreSQL también participan en la integridad de los datos, además de su papel en las consultas.
Actualizar la versión que realmente leíste
Para una API de edición, el cliente puede enviar una versión esperada del recurso. El servidor actualiza solo si esa versión coincide y la incrementa junto al cambio. Si otro cliente ya modificó el recurso, se rechaza el conflicto en lugar de sobrescribirlo silenciosamente.
HTTP ofrece precondiciones como If-Match, que utiliza la comparación fuerte de un ETag para condicionar la petición. El servidor debe conectar esa precondición con una actualización protegida en el almacenamiento: comprobar el ETag y escribir después sin coordinación recrearía la carrera. RFC 9110, sección 13.1.1.
Los permisos también pueden cambiar entre una lectura y una escritura. El criterio de pertenencia, autorización o estado que habilita una mutación debe formar parte de una operación coordinada con la modificación. Los controles de autorización por recurso en una API necesitan considerar esta dimensión temporal.
Finalmente, evitar una carrera no elimina los duplicados causados por reintentos. Si una petición ya se procesó y su respuesta se perdió, necesitas un contrato de idempotencia. Son problemas relacionados, pero una operación que no sobrescribe archivos no garantiza que un pago remoto se ejecute una sola vez.
Cómo probar concurrencia sin depender de la suerte
Una prueba secuencial puede aprobar aunque el diseño sea vulnerable. Conviene empezar por la invariante y buscar el intercalado que la rompe:
- Fija el resultado permitido. Por ejemplo, una sola creación aceptada, ninguna reserva sin stock o ningún cambio sobre una versión obsoleta.
- Controla el punto de intercalado. Introduce una barrera o un punto de sincronización de prueba entre lectura y escritura. El callback del laboratorio cumple esa función sin depender de milisegundos arbitrarios.
- Comprueba el estado final y las respuestas. Dos respuestas de éxito y un solo registro pueden revelar una pérdida de actualización.
- Prueba con los participantes reales. Para una API con réplicas, ejecuta clientes contra procesos distintos y un almacenamiento compartido de laboratorio. Una prueba dentro de un solo proceso no valida un mutex distribuido.
- Incluye errores y recuperación. Interrumpe una escritura de prueba, fuerza conflictos, simula una transacción abortada y comprueba qué queda persistido y qué puede reintentarse.
Repetir operaciones concurrentes ayuda a encontrar errores, pero no demuestra la ausencia de todos los intercalados posibles. Combina esas pruebas con el razonamiento sobre las garantías del almacenamiento y pruebas deterministas del caso que te preocupa.
En actualizadores, incorpora estados intermedios al diseño de pruebas: extracción incompleta, interrupción del proceso, acceso al área temporal y restauración de una versión consistente. Hazlo en un entorno aislado; no es necesario atacar un sitio real para comprobar si tu propio mecanismo de publicación respeta sus invariantes.
Protege la transición, no solo la comprobación
Cuando una operación depende de un estado mutable, la pregunta útil es: ¿qué impide que cambie entre la decisión y el efecto?
A veces basta con una creación exclusiva o una actualización condicional. Otras veces necesitas una transacción con el aislamiento adecuado, un bloqueo compartido por los participantes o un protocolo de publicación. Elegir el mecanismo más pequeño que preserve la regla suele ser mejor que añadir validaciones repetidas sin una garantía de coordinación.
El caso de WordPress recuerda además que los estados transitorios forman parte de la seguridad del sistema. Los archivos temporales, el código del instalador y las operaciones de recuperación merecen el mismo cuidado que la ruta habitual de una petición.
Referencias
- CISA: catálogo KEV, CVE-2026-119977 y feed JSON oficial.
- CVE: registro CVE-2026-119977.
- WordPress: aviso GHSA-4w3m-c66w-69mg.
- WordPress: versión 7.1.4 y parche del actualizador.
- MITRE: CWE-367, TOCTOU.
- Node.js: API del sistema de archivos y event loop y worker pool.
- PostgreSQL: aislamiento de transacciones y bloqueos explícitos.
- RFC 9110: If-Match.
Fuentes del caso consultadas el 6 de noviembre de 2026. La noticia de The Hacker News fue el punto de partida; las condiciones y la corrección se contrastaron con el catálogo, el aviso y el código oficial.
