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
- 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
- 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');
- 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
- 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 unUPDATEque no coincide con ninguna fila:
java -cp r2dbc-mariadb-1.4.1.jar:<deps>:. -Dport=3309 SubmissionPoc
- 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.
- 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 elUPDATEinforma 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
- 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
UPDATEque 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
sslModeen su valor por defecto (DISABLE):VERIFY_FULLal menos elimina la variante del atacante en la ruta de red, aunque no protege frente a un servidor o proxy hostil.