Uploaded image for project: 'MariaDB Server'
  1. MariaDB Server
  2. MDEV-41964

Replica SQL thread hangs applying a row event with a corrupt column count

    XMLWordPrintable

Details

    • Can result in hang or crash

    Description

      A slave accepts a row event with an invalid column count instead of
      rejecting the event as corrupt. When the SQL thread applies that event,
      it loops forever without making progress, and replication on that slave
      stops for good.

      The slave gives no sign that replication has stopped.
      SHOW SLAVE STATUS keeps reporting Slave_SQL_Running as Yes
      and Last_SQL_Errno as 0, and nothing is written to the error log.
      The IO thread keeps receiving the master's binlog, so
      Read_Master_Log_Pos advances while Exec_Master_Log_Pos stays at
      the bad event. Monitoring that watches the status fields sees a healthy
      slave.

      Context

      A row event (WRITE_ROWS, UPDATE_ROWS or DELETE_ROWS) stores
      the number of columns it covers, its width, as a packed integer. A
      bitmap with one bit per column follows, marking which columns the row
      images carry. A packed integer whose first byte is 0xfb is the
      protocol's NULL marker, and net_field_length() returns it as
      NULL_LENGTH (~0) rather than as a number. A width is never NULL, so
      a 0xfb width only comes from a corrupt event.

      The Rows_log_event read constructor in sql/log_event.cc reads
      the width into m_width, a uint, and checks that the event has
      enough bytes left for the bitmap before building it.

      m_width= net_field_length(&ptr_after_width);
      if (ptr_after_width + (m_width + 7) / 8 > (uchar*)buf + event_len)
      { m_cols.bitmap= NULL; DBUG_VOID_RETURN; }
      

      The constructor serves all three row event types and their compressed
      forms. An UPDATE_ROWS event reads a second bitmap of the same width
      for its after image, behind the same check.

      On apply, Rows_log_event::do_apply_event() walks the row images
      from m_curr_row to m_rows_end. Each call to unpack_row()
      reads one field for each bit set in the bitmap, then advances
      m_curr_row past the bytes it read. The loop ends when
      m_curr_row reaches m_rows_end.

      A 0xfb width sets m_width to 0xFFFFFFFF. The bounds check
      computes (m_width + 7) / 8 in 32-bit arithmetic, which wraps to 0,
      so the check finds enough bytes for the bitmap and accepts the event.

      my_bitmap_init() then builds a bitmap of 0xFFFFFFFF bits.
      bitmap_buffer_size() sizes it in 64-bit arithmetic, so the
      allocation is about 512MB, and no bit in the bitmap is set.

      On apply, unpack_row() finds no bits set and reads no bytes of the
      row image. m_curr_row does not advance, so the loop in
      Rows_log_event::do_apply_event() never reaches m_rows_end and
      applies the same row image on every pass. With no unique key, each pass
      writes a row of default values. With a unique key, the second pass
      fails with ER_DUP_ENTRY and the SQL thread stops.

      The Table_map_log_event column count had the same vulnerability and
      was hardened by MDEV-39689. That hardening did not extend to the row
      event width.
      Reproduction (only Insert_rows_log_event is shown)

      Debug Injection

      diff --git a/sql/log_event_server.cc b/sql/log_event_server.cc
      index 962a4f2be2b..7d678f396da 100644
      --- a/sql/log_event_server.cc
      +++ b/sql/log_event_server.cc
      @@ -5804,6 +5804,8 @@ bool Rows_log_event::write_data_body_metadata(Log_event_writer *writer)
         uchar *bitmap;
         DBUG_ASSERT(static_cast<size_t>(sbuf_end - sbuf) <= sizeof(sbuf));
       
      +  DBUG_EXECUTE_IF("corrupt_rows_event_null_width", sbuf[0]= 0xfb;);
      +
         DBUG_DUMP("m_width", sbuf, (size_t) (sbuf_end - sbuf));
         res= res || write_data(writer, sbuf, (size_t) (sbuf_end - sbuf));
      

      From commit

      commit c28d46ab431e1cc00e4ebd284f7032315ae062c0 (HEAD -> knielsen_log_event_read_validation, origin/knielsen_log_event_read_validation)
      Author: Kristian Nielsen <knielsen@knielsen-hq.org>
      Date:   Thu Sep 10 16:08:04 2026 +0200
       
          MDEV-40745: Validate field parsing of optional_metadata
      

      .test file

      --source include/have_debug.inc
      --source include/have_innodb.inc
      --source include/have_binlog_format_row.inc
      --source include/master-slave.inc
       
      --connection master
      # No unique key, so the applier re-runs the first row each pass instead
      # of stopping on a duplicate-key error on the second pass.
      CREATE TABLE t1 (a INT, b INT, c INT) ENGINE=InnoDB;
      sync_slave_with_master;
       
      --echo #
      --echo # Master logs one WRITE_ROWS event whose column-count width byte is
      --echo # forged to 0xfb, the NULL_LENGTH marker.
      --echo #
      --connection master
      SET @@GLOBAL.debug_dbug= '+d,corrupt_rows_event_null_width';
      INSERT INTO t1 VALUES (1, 2, 3);
      SET @@GLOBAL.debug_dbug= '-d,corrupt_rows_event_null_width';
       
      --echo #
      --echo # The slave reads the forged width as 2^32-1, builds a 512MB
      --echo # all-clear bitmap, and unpack_row() consumes no row bytes, so the
      --echo # apply loop never advances and re-runs write_row() forever.
      --echo #
      --connection slave
      --let $wait_condition= SELECT COUNT(*)=1 FROM information_schema.PROCESSLIST WHERE COMMAND='Slave_SQL' AND STATE='Write_rows_log_event::write_row()'
      --source include/wait_condition.inc
      if (!$success)
      {
        die The SQL thread did not wedge in write_row(); the forged event was not applied as the bug requires.;
      }
       
      --echo #
      --echo # Slave positions before next query
      --echo #
      --let $read_pos= query_get_value(SHOW SLAVE STATUS, Read_Master_Log_Pos, 1)
      --let $exec_pos= query_get_value(SHOW SLAVE STATUS, Exec_Master_Log_Pos, 1)
      --echo # Read_Master_Log_Pos = $read_pos
      --echo # Exec_Master_Log_Pos = $exec_pos  (stays here)
       
      --connection master
      INSERT INTO t1 VALUES (4, 5, 6);
      sleep 1;
       
      --echo #
      --echo # The event is read, but never executed.
      --echo # Exec_Master_Log_Pos is frozen at the forged event.
      --echo #
      --connection slave
      --let $read_pos2= query_get_value(SHOW SLAVE STATUS, Read_Master_Log_Pos, 1)
      --let $exec_pos2= query_get_value(SHOW SLAVE STATUS, Exec_Master_Log_Pos, 1)
      --echo # Read_Master_Log_Pos = $read_pos2
      --echo # Exec_Master_Log_Pos = $exec_pos2  (stays here)
       
      --echo #
      --echo # But the slave SQL thread is still running without sign of error
      --echo #
      --let $running= query_get_value(SHOW SLAVE STATUS, Slave_SQL_Running, 1)
      --let $errno= query_get_value(SHOW SLAVE STATUS, Last_SQL_Errno, 1)
      --echo # Slave_SQL_Running = $running
      --echo # Last_SQL_Errno    = $errno
      

      Reported by fg0x0@wearehackerone.com

      Attachments

        Issue Links

          Activity

            People

              knielsen Kristian Nielsen
              bnestere Brandon Nesterenko
              Votes:
              0 Vote for this issue
              Watchers:
              2 Start watching this issue

              Dates

                Created:
                Updated:

                Time Tracking

                  Estimated:
                  Original Estimate - 2d Original Estimate - 2d
                  2d
                  Remaining:
                  Time Spent - 3h Remaining Estimate - 2d
                  2d
                  Logged:
                  Time Spent - 3h Remaining Estimate - 2d
                  3h

                  Git Integration

                    Error rendering 'com.xiplink.jira.git.jira_git_plugin:git-issue-webpanel'. Please contact your Jira administrators.