Race condition en aplicaciones web: portada del artículo
Race Condition en aplicaciones web.

Race Condition en Aplicaciones Web: del Mecanismo al Exploit

Una Race Condition (condición de carrera) es un problema que ocurre cuando varios procesos o hilos de un programa se ejecutan de forma concurrente y el comportamiento del programa varía dependiendo del orden o el tiempo de ejecución.

Cuando ocurre una race condition, existe la posibilidad de que se produzcan bugs no deseados o inconsistencias en los datos, lo que también conduce a vulnerabilidades de seguridad.

Este fenómeno es especialmente propenso a ocurrir en entornos multihilo o cuando varios procesos manipulan datos compartidos al mismo tiempo; dado que los resultados de la ejecución del programa se vuelven inestables, se considera un bug difícil de predecir.

Mecanismo de la race condition

Para entender qué es una race condition de forma operativa, hay que mirar dentro del programa. La race condition ocurre cuando varios procesos o hilos manipulan datos simultáneamente mientras el programa accede a datos compartidos.

Por ejemplo, mientras un hilo lee el valor de un dato compartido, si otro hilo actualiza ese dato, el cálculo que utiliza el dato original dejará de devolver un resultado correcto. La aparición de este conflicto de tiempos provoca una race condition.

Esta vulnerabilidad está catalogada por MITRE como CWE-362 y es una de las debilidades de referencia entre las condiciones de carrera en ciberseguridad.

Diagrama de race condition entre dos hilos sobre un dato compartido
Mecanismo básico de race condition: dos hilos en conflicto de timing sobre el mismo recurso.

Race condition en lenguaje Java: thread-safe vs no thread-safe

A continuación, se presenta un ejemplo de una race condition causada por el acceso a recursos compartidos en el lenguaje Java.

En primer lugar, en el lenguaje Java, las variables locales (variables definidas dentro de un método) son thread-safe (variables almacenadas en el área de stack), pero otras variables de instancia o variables de clase no son thread-safe.

Dado que los valores de las variables que no son thread-safe se almacenan en el área de heap, es posible realizar operaciones como referencia, actualización y eliminación de los datos correspondientes desde múltiples hilos durante la operación multithread.

Ejemplo de Servlet vulnerable

public class SampleServlet extends HttpServlet {
    private String instance_name; // Variable de instancia
    private static String class_name; // Variable de clase

    public static void methodSample(){
        String local_name = "local"; // Variable local
    }

    public void doGet(HttpServletRequest request, HttpServletResponse response)
        throws ServletException, IOException {
        instance_name = request.getParameter("name");
        // Almacena el nombre de usuario en la variable de instancia
        PrintWriter out = response.getWriter();
        out.println("Hello " + instance_name); // Salida directa a la página de respuesta
    }
}

El programa anterior parece no tener problemas a primera vista, pero en este Servlet, dependiendo del timing, los datos de un recurso compartido pueden filtrarse a través de otro hilo cuando se producen múltiples accesos simultáneos.

TOCTOU — Time of Check to Time of Use

TOCTOU (Time of Check to Time of Use) es el nombre genérico de los bugs que ocurren cuando hay un cambio entre el momento del chequeo y el momento del uso.

Aplica sistemáticamente cuando hay una diferencia de tiempo entre la verificación de integridad y el procesamiento real, y suele infiltrarse al realizar verificaciones de integridad. También hay casos donde la race condition está involucrada como un error de lógica, por lo que no se puede generalizar.

Por ejemplo, supongamos que existe un proceso para usar un cupón con el siguiente flujo:

  • Verificar si el cupón existe
  • Usar el cupón y eliminarlo

Supongamos que dos procesos envían solicitudes para el mismo cupón casi al mismo tiempo. Si en el proceso A y el proceso B las tareas se ejecutan en un orden como “A1 → B1 → A2 → B2”, existe la posibilidad de que el uso del cupón se realice dos veces, aunque solo haya un cupón. De esta manera, al no realizar un procesamiento de exclusión mutua adecuado, se genera una condición de carrera para realizar el ataque.

Ejemplo clásico: transferencia de dinero entre usuarios

Imagina una aplicación web que permite a sus usuarios transferir dinero, puntos o créditos entre cuentas. Tomemos a dos usuarios, Juan y Pedro: Juan tiene 100 puntos en su cuenta y Pedro tiene 0. Juan quiere transferir 100 puntos a Pedro. El flujo lógico sería:

  • Asegurarse de que Juan tenga suficiente dinero en su cuenta: Es necesario asegurarse de que la suma esté disponible para que Juan realice la transferencia. Es necesario obtener el valor del balance actual del usuario; si es menor que la suma que desea transferir, hay que comunicárselo. Teniendo en cuenta que en nuestro sitio no se contemplan créditos y el balance no debe quedar en negativo.
  • Restar la suma que se debe transferir del balance del usuario: Es necesario registrar en el valor del balance del usuario actual su balance menos la suma transferida. Había 100, quedó 100-100=0.
  • Añadir al balance del usuario Pedro la suma que se transfirió: Para Pedro, al contrario, había 0, quedó 0+100=100.
  • ¡Mostrar un mensaje al usuario diciéndole que es un genio!

Pseudocódigo

Si (Juan.balance >= suma_transferencia) Entonces
    Juan.Balance = Juan.Balance - suma_transferencia
    Pedro.Balance = Pedro.Balance + suma_transferencia
    Felicitación()
Sino
    Error()

Pero todo estaría bien si todo sucediera en orden de turno. Sin embargo, el sitio puede atender simultáneamente a muchos usuarios, y esto no ocurre en un solo hilo, porque las aplicaciones web modernas utilizan multiprocesamiento y multihilo para el procesamiento paralelo de datos.

Con la aparición del multihilo, en los programas apareció una divertida vulnerabilidad arquitectónica: el estado de carrera (o race condition).

Y ahora imaginemos que nuestro algoritmo se activa simultáneamente 3 veces. Juan sigue teniendo 100 puntos en su balance, solo que de alguna manera se dirigió a la aplicación web con tres hilos simultáneamente (con una cantidad mínima de tiempo entre las solicitudes).

Los tres hilos comprueban si el usuario Pedro existe y comprueban si el balance de Juan es suficiente para la transferencia. En ese momento en que el algoritmo comprueba el balance, este sigue siendo igual a 100. Tan pronto como se pasa la comprobación, del balance actual se resta 100 tres veces y se le añade a Pedro.

¿Qué tenemos? Juan tiene un balance negativo en su cuenta (100 — 300 = -200 puntos). Mientras tanto, Pedro tiene 300 puntos, aunque de hecho, debería tener 100. Este es el ejemplo típico de explotación de un estado de carrera. Es comparable a que por un solo pase pasen varias personas a la vez.

Flujo de explotación de race condition en transferencia bancaria multihilo
Tres hilos concurrentes superan la validación leyendo el mismo balance antes de cualquier escritura.

El estado de carrera puede estar tanto en aplicaciones multihilo como en las bases de datos en las que funcionan. No necesariamente en aplicaciones web; por ejemplo, este es un criterio frecuente para la elevación de privilegios en sistemas operativos. Aunque las aplicaciones web tienen sus propias características para una explotación exitosa, de las cuales vale la pena hablar a continuación.

Otros escenarios de ataque

Subida de archivos al servidor

Cuando un servicio procesa archivos cargados, el flujo del lado del servidor suele ser:

  1. Carga el archivo en la carpeta tmp
  2. El lado del servidor realiza el procesamiento sobre el archivo cargado
  3. Elimina el archivo cargado

El ataque consiste en que, al cargar el archivo en el paso 1, se carga un archivo PHP. Inmediatamente después, se accede a él antes de que sea eliminado en el paso 3 para lograr la ejecución de código arbitrario.

Sitio de compras

Procesamiento del lado del servidor: “Verificar si hay dinero → Procesamiento de compra → Reducir dinero”. Al comprar simultáneamente, puede haber casos en los que el consumo de dinero sea una vez pero se puedan comprar múltiples unidades. Esto es especialmente efectivo cuando el procesamiento de reducción de dinero es “actualizar reduciendo el monto desde lo que se verificó inicialmente que había de dinero”.

Sistema de transferencia de dinero

Procesamiento del lado del servidor: “Transferencia → Registro”. Si esto también se hace simultáneamente, la reducción de dinero es una vez pero se puede enviar múltiples veces. Este escenario ilustra la exigencia técnica que enfrentan operadores con transacciones financieras simultáneas en milisegundos a gran escala, como ocurre en plataformas de apuestas en vivo Betway, donde la validación del estado debe completarse antes de procesar cada operación concurrente.

Gestión de sesiones

En una aplicación web, cuando el mismo usuario intenta iniciar sesión simultáneamente desde múltiples ventanas del navegador o dispositivos, las actualizaciones de la información de sesión pueden entrar en conflicto, perdiendo la integridad de los datos y abriendo puertas a un posible secuestro de sesión. Esto genera el riesgo de que el estado de inicio de sesión del usuario se vuelva inestable o que la sesión se cierre forzosamente.

Conflicto de tokens o identificadores

Al generar tokens o identificadores únicos en una API o sistema de autenticación, si varios procesos generan identificadores simultáneamente, pueden producirse identificadores duplicados, lo que provoca un riesgo de seguridad. Esta situación es también un ejemplo de inconsistencia causada por una race condition.

Explotación típica con Burp Suite

En la mayoría de los casos, para diseñar un exploit de race condition y verificar su impacto, se utiliza software multihilo como cliente. Por ejemplo, Burp Suite y su herramienta Intruder permiten configurar una solicitud HTTP para repetición, establecer muchos hilos y activar el flood.

Este es un método bastante funcional si el servidor permite el uso de muchos hilos para su recurso, y si no funcionó, inténtalo de nuevo. Pero el asunto es que, en algunas situaciones, esto puede no ser eficiente.

Desde Burp Suite 2023.9, Repeater incorpora “Send group in parallel” para ejecutar el single-packet attack (SPA) sobre HTTP/2, técnica que hoy es el estándar para explotar una race condition con Burp Suite sobre objetivos remotos.

Cada hilo establece una conexión TCP, envía datos, espera una respuesta, cierra la conexión, la abre de nuevo, envía datos y así sucesivamente.

A primera vista, todos los datos se envían simultáneamente, pero las propias solicitudes HTTP pueden llegar de forma no sincrónica y en desorden debido a las características de la capa de transporte, la necesidad de establecer una conexión segura (HTTPS), resolver el DNS (no en el caso de Burp) y las múltiples capas de abstracción por las que pasan los datos antes de enviarse al dispositivo de red. Cuando se trata de milisegundos, esto puede jugar un papel clave.

HTTP/1.1 pipelining para minimizar el intervalo entre solicitudes

Se puede recordar el HTTP-Pipelining, en el cual se pueden enviar datos utilizando un solo socket. Tú mismo puedes ver cómo funciona esto usando la utilidad netcat. De hecho, herramientas como netcat suelen utilizarse en entornos GNU/Linux por conveniencia y control sobre el stack TCP/IP, pero el comportamiento relevante depende de la implementación TCP/IP y del control sobre la transmisión de datos a bajo nivel.

Por ejemplo, ejecuta el comando nc google.com 80 e inserta allí las líneas:

GET / HTTP/1.1
Host: google.com

GET / HTTP/1.1
Host: google.com

GET / HTTP/1.1
Host: google.com

De esta manera, dentro de una sola conexión se enviarán tres solicitudes HTTP, y recibirás tres respuestas HTTP. Esta característica se puede utilizar para minimizar el tiempo entre las solicitudes.

Procesamiento secuencial en el servidor

El servidor web recibirá las solicitudes secuencialmente (palabra clave) y procesará las respuestas en orden de turno. Esta característica se puede utilizar para un ataque en varios pasos (cuando es necesario realizar secuencialmente dos acciones en la mínima cantidad de tiempo) o, por ejemplo, para ralentizar el trabajo del servidor en la primera solicitud para aumentar el éxito del ataque.

El truco: tú puedes estorbar al servidor para que procese tu solicitud cargando su DBMS, especialmente efectivo si se utiliza INSERT/UPDATE. Las solicitudes más pesadas pueden “frenar” tu carga, por lo tanto, habrá una mayor probabilidad de que ganes esta carrera.

División de la solicitud HTTP en dos partes

Para empezar, recuerda cómo se forma una solicitud HTTP: la primera línea con método, ruta y versión del protocolo, seguida de los encabezados hasta el salto de línea. Pero, ¿cómo sabe el servidor web que la solicitud HTTP ha terminado?

Veamos un ejemplo. Ingresa nc google.com 80, y allí:

GET / HTTP/1.1
Host: google.com

Después de que presiones ENTER, no pasará nada. Presiona una vez más y verás la respuesta. Es decir, para que el servidor web acepte la solicitud HTTP, son necesarios dos saltos de línea. Una solicitud correcta se ve así: GET / HTTP/1.1\r\nHost: google.com\r\n\r\n. Si fuera el método POST (no olvidemos el Content-Length), la solicitud HTTP correcta sería:

echo -ne "POST / HTTP/1.1\r\nHost: google.com\r\nContent-Length: 3\r\n\r\na=1" | nc google.com 80

Si quitas el último carácter \n, no obtendrás respuesta. De hecho, para muchos servidores web es suficiente usar \n como salto de línea, por lo tanto, es importante no intercambiar de lugar \r y \n, de lo contrario, los siguientes trucos podrían no funcionar.

¿Qué permite esto? Tú puedes abrir simultáneamente muchas conexiones al recurso, enviar el 99% de tu solicitud HTTP y dejar sin enviar el último byte. El servidor esperará hasta que envíes el último carácter de salto de línea.

Después de que esté claro que la mayor parte de los datos ha sido enviada, envía el último byte (o varios). Esto es especialmente importante si se trata de una solicitud POST grande, por ejemplo, cuando es necesario cargar un archivo. Pero incluso en una solicitud pequeña esto tiene sentido, ya que entregar unos pocos bytes es mucho más rápido que enviar simultáneamente kilobytes de información.

Retraso antes de enviar la segunda parte de la solicitud

No solo es necesario fragmentar la solicitud, sino que también tiene sentido hacer un retraso de unos segundos entre el envío de la parte principal de los datos y la parte final. Y todo porque los servidores web comienzan a parsear las solicitudes incluso antes de recibirlas por completo.

Parsing temprano en nginx

Por ejemplo, nginx, al recibir los encabezados de la solicitud HTTP, comenzará a parsearlos, almacenando temporalmente la solicitud incompleta en buffers internos de memoria. Cuando llegue el último byte, el servidor web tomará la solicitud parcialmente procesada y la enviará directamente a la aplicación, con lo cual se reduce el tiempo de procesamiento de las solicitudes, lo que aumenta la probabilidad del ataque.

Características de las sesiones en race condition

Una de las características de las sesiones puede ser que ella misma por sí sola estorbe para explotar la carrera. Por ejemplo, en el lenguaje PHP, después de session_start() ocurre un bloqueo del archivo de sesión, y su desbloqueo ocurrirá solo al finalizar el trabajo del script (si no hubo una llamada a session_write_close). Si en ese momento se llama a otro script que utiliza la sesión, este esperará.

Para evadir esta característica se puede usar un truco simple: realizar la autenticación el número necesario de veces. Si la aplicación web permite crear múltiples sesiones para un solo usuario, simplemente recolectamos todos los PHPSESSID y le asignamos a cada solicitud su propio identificador.

Cercanía al servidor

Si el sitio en el que es necesario explotar el race condition está alojado en AWS, toma una máquina en AWS. Si es en DigitalOcean, tómala allí. Cuando la tarea es enviar solicitudes y minimizar el intervalo de envío entre ellas, la cercanía directa al servidor web será sin duda una ventaja: hay una diferencia clara entre un ping de 200 ms y uno de 10 ms.

Efectos de una race condition

Cuando ocurre una race condition, se producen los siguientes efectos en el programa o sistema:

  • Inconsistencia de datos: Debido a la race condition, los datos pueden volverse inconsistentes y se pueden obtener resultados incorrectos. Esto disminuye la confiabilidad del sistema y conduce a proporcionar información errónea a los usuarios.
  • Crash o errores inesperados: Por la race condition, el programa puede caer en un estado anormal, y existe la posibilidad de que ocurran crashes o errores. Especialmente si los datos se corrompen, el funcionamiento del sistema tiende a volverse inestable.
  • Aumento de riesgos de seguridad: Si se explota una vulnerabilidad de race condition, se vuelven posibles ataques para obtener privilegios específicos o acceder a datos importantes, por lo que el riesgo de seguridad aumenta. Particularmente, una race condition en un sistema de autenticación puede ser causa de un bypass de autenticación.

Causas y prevención de una race condition

Para entender cómo prevenir una race condition hay que asumir primero que es esencialmente un problema arquitectónico causado por no establecer un control de exclusión mutua adecuado para los recursos compartidos.

Probar la validez de ese control en procesamiento paralelo es extremadamente difícil y, dado que se requiere un timing específico para que el problema se manifieste, no son pocas las aplicaciones web que operan manteniendo el problema.

El riesgo se introduce especialmente cuando se usa un RDBMS sin considerar las transacciones o cuando el procesamiento de entrada/salida de archivos no realiza bloqueos físicos o lógicos. A continuación, las medidas para prevenirla.

1. Control de exclusión mutua (mecanismos de bloqueo)

Al implementar procesamiento paralelo, establece un control de exclusión mutua adecuado. Al acceder a datos compartidos, utiliza un control de exclusión (Mutex, semáforos, etc.) para evitar que varios hilos modifiquen los datos al mismo tiempo. Mediante el control de exclusión, mientras un hilo accede a los datos, los otros hilos esperan y se mantiene la integridad de los datos.

Los siguientes son ejemplos de procesos que requieren control de exclusión mutua:

  • Procesamiento en multithread (recursos compartidos como variables)
  • Acceso a bases de datos que requieren transacciones (COMMIT, ROLLBACK)
  • Control de exclusión mutua al acceder a archivos

2. Bloqueos a nivel de DBMS

La operación bloquea en el DBMS el acceso al objeto bloqueado hasta que se desbloquee. Los demás se quedan esperando a un lado. Es necesario trabajar correctamente con los bloqueos, no bloquear nada innecesario.

3. Procesamiento de transacciones

Al acceder a una base de datos, utiliza transacciones para ejecutar una serie de procesos de forma conjunta. Mediante las transacciones se asegura la integridad de los datos y, aunque varios procesos o hilos accedan a los datos simultáneamente, se puede controlar que los datos no se vuelvan inconsistentes a mitad del proceso.

Transacciones ordenadas (serializable): garantizan que las transacciones se ejecuten estrictamente de forma secuencial, sin embargo, esto puede afectar el rendimiento.

4. Semáforos mutex

Toman algún recurso (por ejemplo, etcd). En el momento de la llamada a las funciones, crean un registro con una clave; si no se pudo crear el registro, significa que ya existe y entonces la solicitud se interrumpe. Al terminar el procesamiento de la solicitud, el registro se elimina.

5. Estructuras de datos thread-safe

Al utilizar estructuras de datos thread-safe (como ConcurrentHashMap), el acceso a los datos compartidos se gestiona adecuadamente y se puede reducir el riesgo de que ocurra una race condition. Estas estructuras de datos realizan internamente el control de exclusión entre hilos, por lo que se puede evitar el conflicto entre ellos.

6. Aprovechamiento de objetos inmutables

Los objetos inmutables (objetos que no cambian), una vez creados, no cambian su estado, por lo que no ocurren cambios en los datos aunque se compartan entre hilos. Gracias a esto, es difícil que ocurra una inconsistencia de datos y se evita la race condition.

7. Operaciones atómicas

Si se trata de operaciones simples como incrementos o decrementos, al utilizar operaciones atómicas (atomic operation), es posible completar la operación de una sola vez y prevenir el conflicto con otros hilos. Al utilizar operaciones atómicas, se vuelve difícil que ocurra un conflicto de datos.

8. Procesamiento asíncrono y colas

Al utilizar procesamiento asíncrono o colas, se pueden procesar las solicitudes de varios hilos o procesos en orden, evitando conflictos. Especialmente al utilizar colas, es posible realizar un procesamiento secuencial mientras se gestionan las solicitudes.

RacePWN, single-packet attack y herramientas modernas

Sobre estos principios se desarrolló la herramienta RacePWN, que sigue siendo útil en escenarios HTTP/1.1 con control fino del stack TCP. Consta de dos componentes:

  • La librería librace en lenguaje C, que en el tiempo mínimo y utilizando la mayoría de los trucos del artículo envía múltiples solicitudes HTTP al servidor.
  • La utilidad racepwn, que recibe como entrada una configuración json y, en general, gestiona esta librería.

RacePWN se puede integrar en otras utilidades (por ejemplo, en Burp Suite), o crear una interfaz web para gestionar los races.

El estándar moderno: single-packet attack (SPA)

Diagrama de single packet attack sobre HTTP/2 frente a conexiones TCP paralelas
SPA elimina el jitter de red al encapsular múltiples requests en una sola unidad de transmisión TCP.

A partir de la investigación de PortSwigger sobre race condition publicada como Smashing the State Machine (2023) y presentada en Black Hat USA 2023, el estándar real de explotación remota es el single-packet attack (SPA) sobre HTTP/2.

A diferencia del enfoque clásico de muchas conexiones TCP en paralelo, SPA aprovecha la multiplexación de streams de HTTP/2 para encapsular 20-30 requests en una única unidad de transmisión TCP, eliminando casi por completo el jitter de red y logrando que todas las solicitudes lleguen al backend con un spread de ~1 ms o menos.

Las herramientas que hoy implementan SPA de forma nativa son:

  • Burp Suite Repeater con la opción “Send group in parallel” (disponible desde la versión 2023.9).
  • Turbo Intruder con el template race-single-packet-attack.py.
  • Frameworks experimentales para HTTP/3 (QUIC) que implementan el equivalente single-datagram attack (SDA), aprovechando la entrega independiente de streams en QUIC.

Esto implica una corrección importante respecto a versiones tempranas del estado del arte: HTTP/2 ya no es solo un front que hace proxy a HTTP/1.1. Su multiplexación de múltiples streams sobre una sola conexión es precisamente lo que habilita SPA y lo que hace que sea hoy más confiable que cualquier técnica HTTP/1.1.

En muchos despliegues aún existe traducción hacia HTTP/1.1 en backends, lo que introduce nuevas clases de vulnerabilidades — pero no debe asumirse que HTTP/2 actúa únicamente como una capa estética.

Race condition: vigencia, mitigación y testing actual

En aplicaciones web actuales sigue siendo una clase de vulnerabilidad relevante, particularmente en lógica de negocio (double-spend, bypass de límites, duplicación de acciones), incluso en arquitecturas distribuidas y entornos HTTP/2+.

Para prevenir la race condition, es efectivo aprovechar métodos como el control de exclusión, transacciones, estructuras de datos thread-safe, objetos inmutables, operaciones atómicas y, en sistemas distribuidos modernos, patrones como idempotency keys y optimistic locking.

De esta manera, se puede asegurar la estabilidad del sistema y la integridad de los datos, logrando programas seguros y de alta confiabilidad — una disciplina central en cualquier práctica de pentesting moderno.

El testing efectivo requiere herramientas con soporte para single-packet synchronization y análisis cuidadoso de las race windows en el flujo de negocio.

Mi Carro Close (×)

Tu carrito está vacío
Ver tienda