From b34a20a1f23ad82d55164ec9bd746620b79e4afd Mon Sep 17 00:00:00 2001 From: Brandon Nesterenko Date: Mon, 10 Aug 2026 11:03:35 -0600 Subject: [PATCH 1/2] MDEV-40646 (Regression): Undersized Table_map Metadata can Crash Slave --- .../t/rpl_table_map_metadata_underflow.test | 114 ++++++++++++++++++ sql/sql_repl.cc | 51 ++++++++ 2 files changed, 165 insertions(+) create mode 100644 mysql-test/suite/rpl/t/rpl_table_map_metadata_underflow.test diff --git a/mysql-test/suite/rpl/t/rpl_table_map_metadata_underflow.test b/mysql-test/suite/rpl/t/rpl_table_map_metadata_underflow.test new file mode 100644 index 0000000000000..2f081f062d2cb --- /dev/null +++ b/mysql-test/suite/rpl/t/rpl_table_map_metadata_underflow.test @@ -0,0 +1,114 @@ +# +# This test verifies that a replica rejects a Table_map event whose field +# metadata is shorter than the metadata the column types it declares require. +# The replica must stop the SQL thread with an error rather than decode the +# metadata past the end of what the event carries, and replication must resume +# once a well formed copy of the event arrives. +# +# MDEV-40646 reported that such an event read past the metadata buffer. A +# build with AddressSanitizer is the reliable witness of that read, aborting +# at the first byte past the allocation. A regular debug build does not fault +# on the read: it decodes adjacent heap and stops the SQL thread with a +# conversion error whose reported type is rendered from those bytes. This test +# asserts the rejection the fix adds ahead of the read, which holds on every +# build. +# +# References: +# * MDEV-40646: Slave Crashes in table_def::table_def() on Malformed +# Table_map +# + +--source include/have_debug.inc +# A Table_map event is only written under row based binary logging. +--source include/have_binlog_format_row.inc +--source include/master-slave.inc + +--connection slave +call mtr.add_suppression("Found invalid event in binary log"); +call mtr.add_suppression("Relay log read failure: Could not parse relay log event entry"); + +--echo # +--echo # Initialize test data +--echo # + +--connection slave +# Use slave_pos so we can easily reset the relay log after it receives a +# corrupted event +--source include/stop_slave.inc +CHANGE MASTER TO MASTER_USE_GTID=slave_pos; +--source include/start_slave.inc + +--connection master +# With 24 columns, the metadata reads run well past the end of the replica's +# allocation once the event declares a single metadata byte. +create table t1 (c1 int,c2 int,c3 int,c4 int,c5 int,c6 int,c7 int,c8 int,c9 int,c10 int,c11 int,c12 int,c13 int,c14 int,c15 int,c16 int,c17 int,c18 int,c19 int,c20 int,c21 int,c22 int,c23 int,c24 int); +insert into t1 (c1) values (0); +--source include/save_master_gtid.inc + +--connection slave +--source include/sync_with_master_gtid.inc + +--connection master +set @saved_dbug= @@global.debug_dbug; + +--echo # +--echo # Test Case: a Table_map declaring more metadata than it carries +--echo # + +# The reconnect below starts a dump thread that inherits the injection. +--connection master +set @@global.debug_dbug= "+d,binlog_sender_undersized_table_map_metadata"; + +--connection slave +--source include/stop_slave.inc +--source include/start_slave.inc + +--connection master +insert into t1 (c1) values (1); + +--connection slave +--echo # Waiting for the SQL thread to reject the malformed Table_map +--let $slave_sql_errno= 1594 +--source include/wait_for_slave_sql_error.inc + +--echo # Ensure the event was rejected before its metadata was decoded +--let $assert_text= The SQL thread reported an invalid event +--let $assert_file= $MYSQLTEST_VARDIR/log/mysqld.2.err +--let $assert_select= Found invalid event in binary log +--let $assert_count= 1 +--source include/assert_grep.inc + +--echo # +--echo # Recover: discard the poisoned relay log and re-fetch a clean copy +--echo # +--connection master +set @@global.debug_dbug= @saved_dbug; + +--connection slave +--source include/stop_slave_io.inc +--source include/start_slave.inc + +--echo # +--echo # Ensure replication works after the invalid event +--echo # +--connection master +insert into t1 (c1) values (2); +--source include/save_master_gtid.inc + +--connection slave +--source include/sync_with_master_gtid.inc +--let $diff_tables=master:t1,slave:t1 +--source include/diff_tables.inc + +--echo # +--echo # Cleanup +--echo # +--connection master +drop table t1; +--source include/save_master_gtid.inc + +--connection slave +--source include/sync_with_master_gtid.inc + +--source include/rpl_end.inc +--echo # End of rpl_table_map_metadata_underflow.test diff --git a/sql/sql_repl.cc b/sql/sql_repl.cc index 25d5c9e0a585f..c77e7018ecb78 100644 --- a/sql/sql_repl.cc +++ b/sql/sql_repl.cc @@ -2035,6 +2035,57 @@ send_event_to_slave(binlog_send_info *info, Log_event_type event_type, return "run 'before_send_event' hook failed"; } +#ifndef DBUG_OFF + /* + Rewrite the body of a Table_map event so it declares columns whose types + need more field metadata than the event carries, to test that a replica + bounds the metadata before decoding it. Every column becomes + MYSQL_TYPE_STRING, which reads two metadata bytes, and the event is left + with a single metadata byte, so a replica missing the bound reads past the + metadata while decoding the columns. The rewrite keeps the column count the + master already wrote, which the test fixes at 24 columns, enough to carry + the metadata reads well past the end of the replica's allocation. + + The event is rewritten here, on the wire, rather than in the master's own + binary log, so the injection lands on the copy the dump thread sends and + the master's binary log stays well formed. + */ + DBUG_EXECUTE_IF("binlog_sender_undersized_table_map_metadata", + { + if (event_type == TABLE_MAP_EVENT) + { + /* The rewritten event can be one byte longer than the original. */ + packet->realloc(len + 1); + uchar *ev= (uchar*) packet->ptr() + ev_offset; + uchar *p= ev + LOG_EVENT_HEADER_LEN + TABLE_MAP_HEADER_LEN; + uint db_len= *p; + p+= 1 + db_len + 1; // db name length byte, name, null + uint tbl_len= *p; + p+= 1 + tbl_len + 1; // table name length byte, name, null + uint colcnt= *p++; // column count (one packed byte) + uchar *coltype= p; + + memset(coltype, MYSQL_TYPE_STRING, colcnt); + uchar *w= coltype + colcnt; + *w++= 1; // field metadata size (one byte) + *w++= 0; // the single metadata byte + uint null_bytes= (colcnt + 7) / 8; + memset(w, 0, null_bytes); + w+= null_bytes; + + ulong new_ev_len= (ulong) (w - ev); + if (current_checksum_alg == BINLOG_CHECKSUM_ALG_CRC32) + new_ev_len+= BINLOG_CHECKSUM_LEN; + int4store(ev + EVENT_LEN_OFFSET, new_ev_len); + if (current_checksum_alg == BINLOG_CHECKSUM_ALG_CRC32) + int4store(ev + new_ev_len - BINLOG_CHECKSUM_LEN, + my_checksum(0, ev, new_ev_len - BINLOG_CHECKSUM_LEN)); + len= ev_offset + new_ev_len; + packet->length(len); + } + }); +#endif + if (my_net_write(info->net, (uchar*) packet->ptr(), len)) { info->error= ER_UNKNOWN_ERROR; From 2930e592e1fa7402ad2a0af0920421ac19a1882a Mon Sep 17 00:00:00 2001 From: Brandon Nesterenko Date: Mon, 10 Aug 2026 11:03:49 -0600 Subject: [PATCH 2/2] MDEV-40646: Undersized Table_map Metadata can Crash Slave A slave can crash or leak heap content when its master sends a Table_map event that declares columns whose types need more field metadata than the event carries. Building its description of the table, the slave reads past the end of the field metadata the event holds. The bytes beyond the buffer are decoded as column metadata. A Table_map event carries a column count, one type byte per column, and a block of field metadata. A column's type fixes how many metadata bytes it consumes: two for the string, enum, set, bit, varchar, and decimal types, one for the blob, float, and high precision temporal types, none for the rest. The master writes exactly that many bytes per column, so the metadata block a well formed event carries is the sum over its columns. The slave sizes the metadata block from the event and, while building its table_def, walks the columns in order and reads each type's metadata bytes from that block. The slave checked only that the metadata block was no larger than two bytes per column. It never checked the block against the column types themselves. table_def() then walked every declared column and indexed the metadata block for each, so a column whose type needed metadata the block did not hold read past the end of it. A column count large enough carried that read far past the allocation. The Table_map reader now sums the metadata the declared column types consume and rejects the event when the block is smaller than that sum, so is_valid() reports the event as invalid and the SQL thread stops with a relay log read failure before any column is decoded. A block larger than the sum is still accepted, because every consumer reads only the bytes the types call for. A column type this slave does not recognize counts as zero in the sum, so Table_map events from a newer master using such a type still parse. A Table_map whose metadata cannot cover its column types now stops the slave with an error instead of reading past the buffer. Note that the regression test does not crash a regular debug build; an ASAN build is required to show the invalid memory access. The 24 column table map it injects overruns the metadata allocation by a bounded amount that lands in mapped heap, which a debug build reads without faulting and then stops the SQL thread with ER_SLAVE_CONVERSION_FAILED on the column type mismatch. Reviewed-by: TODO Signed-off-by: Brandon Nesterenko --- .../r/rpl_table_map_metadata_underflow.result | 62 +++++++++++++++++++ sql/log_event.cc | 16 +++++ sql/rpl_utility.cc | 28 +++++++++ sql/rpl_utility.h | 6 ++ 4 files changed, 112 insertions(+) create mode 100644 mysql-test/suite/rpl/r/rpl_table_map_metadata_underflow.result diff --git a/mysql-test/suite/rpl/r/rpl_table_map_metadata_underflow.result b/mysql-test/suite/rpl/r/rpl_table_map_metadata_underflow.result new file mode 100644 index 0000000000000..0023b572b7eb1 --- /dev/null +++ b/mysql-test/suite/rpl/r/rpl_table_map_metadata_underflow.result @@ -0,0 +1,62 @@ +include/master-slave.inc +[connection master] +connection slave; +call mtr.add_suppression("Found invalid event in binary log"); +call mtr.add_suppression("Relay log read failure: Could not parse relay log event entry"); +# +# Initialize test data +# +connection slave; +include/stop_slave.inc +CHANGE MASTER TO MASTER_USE_GTID=slave_pos; +include/start_slave.inc +connection master; +create table t1 (c1 int,c2 int,c3 int,c4 int,c5 int,c6 int,c7 int,c8 int,c9 int,c10 int,c11 int,c12 int,c13 int,c14 int,c15 int,c16 int,c17 int,c18 int,c19 int,c20 int,c21 int,c22 int,c23 int,c24 int); +insert into t1 (c1) values (0); +include/save_master_gtid.inc +connection slave; +include/sync_with_master_gtid.inc +connection master; +set @saved_dbug= @@global.debug_dbug; +# +# Test Case: a Table_map declaring more metadata than it carries +# +connection master; +set @@global.debug_dbug= "+d,binlog_sender_undersized_table_map_metadata"; +connection slave; +include/stop_slave.inc +include/start_slave.inc +connection master; +insert into t1 (c1) values (1); +connection slave; +# Waiting for the SQL thread to reject the malformed Table_map +include/wait_for_slave_sql_error.inc [errno=1594] +# Ensure the event was rejected before its metadata was decoded +include/assert_grep.inc [The SQL thread reported an invalid event] +# +# Recover: discard the poisoned relay log and re-fetch a clean copy +# +connection master; +set @@global.debug_dbug= @saved_dbug; +connection slave; +include/stop_slave_io.inc +include/start_slave.inc +# +# Ensure replication works after the invalid event +# +connection master; +insert into t1 (c1) values (2); +include/save_master_gtid.inc +connection slave; +include/sync_with_master_gtid.inc +include/diff_tables.inc [master:t1,slave:t1] +# +# Cleanup +# +connection master; +drop table t1; +include/save_master_gtid.inc +connection slave; +include/sync_with_master_gtid.inc +include/rpl_end.inc +# End of rpl_table_map_metadata_underflow.test diff --git a/sql/log_event.cc b/sql/log_event.cc index f72423718db59..d77dff632e713 100644 --- a/sql/log_event.cc +++ b/sql/log_event.cc @@ -3766,6 +3766,22 @@ Table_map_log_event::Table_map_log_event(const uchar *buf, uint event_len, m_field_metadata_size= net_field_length(&ptr_after_colcnt); DBUG_EXECUTE_IF("corrupt_table_map_field_metadata_size_read", m_field_metadata_size= (1 << 20);); + + /* + Ensure the field metadata block covers the metadata the declared + column types consume. + */ + size_t metadata_needed= 0; + for (ulong i= 0; i < m_colcnt; i++) + metadata_needed+= table_def::field_metadata_length(m_coltype[i]); + if (unlikely(m_field_metadata_size < metadata_needed)) + { + m_coltype= NULL; + my_free(m_memory); + m_memory= NULL; + DBUG_VOID_RETURN; + } + if (m_field_metadata_size <= (m_colcnt * 2)) { uint num_null_bytes= (m_colcnt + 7) / 8; diff --git a/sql/rpl_utility.cc b/sql/rpl_utility.cc index 9ea8bb3b82256..3f2d314a9fcb6 100644 --- a/sql/rpl_utility.cc +++ b/sql/rpl_utility.cc @@ -173,6 +173,34 @@ uint32 table_def::calc_field_size(uint col, uchar *master_data) const PSI_memory_key key_memory_table_def_memory; +uint table_def::field_metadata_length(uint binlog_type) +{ + switch (static_cast(binlog_type)) { + case MYSQL_TYPE_TINY_BLOB: + case MYSQL_TYPE_BLOB: + case MYSQL_TYPE_BLOB_COMPRESSED: + case MYSQL_TYPE_MEDIUM_BLOB: + case MYSQL_TYPE_LONG_BLOB: + case MYSQL_TYPE_DOUBLE: + case MYSQL_TYPE_FLOAT: + case MYSQL_TYPE_GEOMETRY: + case MYSQL_TYPE_TIME2: + case MYSQL_TYPE_DATETIME2: + case MYSQL_TYPE_TIMESTAMP2: + return 1; + case MYSQL_TYPE_SET: + case MYSQL_TYPE_ENUM: + case MYSQL_TYPE_STRING: + case MYSQL_TYPE_BIT: + case MYSQL_TYPE_VARCHAR: + case MYSQL_TYPE_VARCHAR_COMPRESSED: + case MYSQL_TYPE_NEWDECIMAL: + return 2; + default: + return 0; + } +} + table_def::table_def(unsigned char *types, ulong size, uchar *field_metadata, int metadata_size, uchar *null_bitmap, uint16 flags) diff --git a/sql/rpl_utility.h b/sql/rpl_utility.h index c28e8aa10ebef..712855cb5c30b 100644 --- a/sql/rpl_utility.h +++ b/sql/rpl_utility.h @@ -74,6 +74,12 @@ class table_def { return static_cast(m_type[index]); } + + /* + Number of field-metadata bytes a column of the given binlog type occupies + in a Table_map event. + */ + static uint field_metadata_length(uint binlog_type); /* Return a representation of the type data for one field.