From b0b8513dae53038cef3503264ec606830c0fbc0d Mon Sep 17 00:00:00 2001 From: Arcadiy Ivanov Date: Tue, 8 Sep 2026 16:22:51 -0400 Subject: [PATCH] MDEV-27562 MSAN use-of-uninitialized-value in cmp_buffer_with_ref `cmp_buffer_with_ref()` decides whether an `eq_ref` lookup can reuse the row the previous lookup fetched, and it decides it by comparing the whole ref key buffer with `memcmp()`. A key part over a `VARCHAR` is a fixed width image, so the bytes that follow the value are compared as well, although they hold no information. `store_key` fills that buffer through `field_conv()`, which for two identical `VARCHAR` fields takes the `field_conv_memcpy()` shortcut and copies `pack_length()` bytes. The bytes after the value are therefore whatever the source row carried there, and a storage engine is entitled to leave that part of a record undefined. InnoDB marks it so in `row_sel_field_store_in_mysql_format_func()` and Aria in `_ma_read_block_record2()`, and a MemorySanitizer build then aborts the server inside the comparison. The sibling path is already clean. `store_key_field` copies through `Copy_field`, which for the same pair of fields picks `do_varstring2_no_truncation()` and writes only the length prefix and the value, leaving the rest as the preceding `bzero()` left it. The comparison is a shortcut rather than a correctness decision: a difference in those bytes only causes an already fetched row to be fetched again. Mark them defined, which is what `Count_distinct_field::add()` already does for the same reason. The field the marking is applied to describes the key part rather than the column, because `copy_keys_from_share()` gives a key part over a prefix its own field narrowed to the key part's length, so the range marked is the slot inside the key buffer and not the width of the column. `Field::mark_unused_memory_as_defined()` is an empty inline unless the server is built with memory instrumentation, so a production build is unchanged. The test reaches the comparison through a multi-table update whose condition is cached, through a correlated subquery, which lands at a different offset in the same buffer, and once more with the outer table on Aria, so that both engines named above are covered. Values of differing length are used so that the bytes after a short value are the ones a longer value left behind. The column has to be wider than 255 bytes for the full width copy to be chosen, since a key field always carries a two byte length prefix and `memcpy_field_possible()` requires the source to agree. No build without memory instrumentation can fail on the read, so each block also asserts that the subquery cache counters moved, which is what shows the comparison was reached at all. Only whether they moved is asserted, because a prepared statement executes the subquery twice and doubles them. MDEV-32436 reports the correlated shape. The Valgrind errors it also reports come from InnoDB's insert path and are a separate matter. --- .../subquery_cache_varchar_ref_key.result | 118 ++++++++++++++++++ .../main/subquery_cache_varchar_ref_key.test | 88 +++++++++++++ sql/sql_select.h | 14 +++ 3 files changed, 220 insertions(+) create mode 100644 mysql-test/main/subquery_cache_varchar_ref_key.result create mode 100644 mysql-test/main/subquery_cache_varchar_ref_key.test diff --git a/mysql-test/main/subquery_cache_varchar_ref_key.result b/mysql-test/main/subquery_cache_varchar_ref_key.result new file mode 100644 index 0000000000000..8586861f71e9d --- /dev/null +++ b/mysql-test/main/subquery_cache_varchar_ref_key.result @@ -0,0 +1,118 @@ +# +# A ref lookup key built over a VARCHAR is a fixed width image, so the +# bytes that follow the value take part in the key comparison that +# decides whether the previous lookup can be reused, although they +# carry no information. A storage engine is free to leave that part of +# a record undefined, and the subquery cache reaches that comparison +# once per row. Only a build with memory instrumentation can fail on +# the read, so each block below also checks that the subquery cache +# counters moved, which is what shows the comparison was reached at +# all. Only whether they moved is checked, because a prepared +# statement executes the subquery twice and doubles them. +# +# The column has to be wider than 255 bytes. A key field always carries +# a two byte length prefix, and the full width copy that leaves the +# bytes after the value in place is taken only when the source field +# agrees on the width of that prefix. A narrower column takes a +# conversion path that writes the value alone. +# +CREATE TABLE t1 (id INT PRIMARY KEY, a VARCHAR(256)) ENGINE=InnoDB; +INSERT INTO t1 VALUES (1,'foo'),(2,'bar'),(3,'foo'),(4,'quux'), +(5,'bar'),(6,'foo'),(7,'corge'),(8,'bar'); +CREATE TABLE t2 (x INT) ENGINE=InnoDB; +INSERT INTO t2 VALUES (1),(2); +# Values of differing length, so that the bytes after a short one are +# whatever the longer row before it left behind +# The predicate is false for every row, so nothing is updated +FLUSH STATUS; +UPDATE t1, t2 SET id = id + 100 WHERE a IN ( SELECT 'baz' UNION SELECT 'qux' ); +SELECT VARIABLE_NAME, VARIABLE_VALUE > 0 AS reached +FROM information_schema.SESSION_STATUS +WHERE VARIABLE_NAME LIKE 'SUBQUERY_CACHE%' ORDER BY VARIABLE_NAME; +VARIABLE_NAME reached +SUBQUERY_CACHE_HIT 1 +SUBQUERY_CACHE_MISS 1 +SELECT id, a FROM t1 ORDER BY id; +id a +1 foo +2 bar +3 foo +4 quux +5 bar +6 foo +7 corge +8 bar +# The same lookups with three rows qualifying +FLUSH STATUS; +UPDATE t1, t2 SET id = id + 10 WHERE a IN ( SELECT 'foo' UNION SELECT 'qux' ); +SELECT VARIABLE_NAME, VARIABLE_VALUE > 0 AS reached +FROM information_schema.SESSION_STATUS +WHERE VARIABLE_NAME LIKE 'SUBQUERY_CACHE%' ORDER BY VARIABLE_NAME; +VARIABLE_NAME reached +SUBQUERY_CACHE_HIT 1 +SUBQUERY_CACHE_MISS 1 +SELECT id, a FROM t1 ORDER BY id; +id a +2 bar +4 quux +5 bar +7 corge +8 bar +11 foo +13 foo +16 foo +DROP TABLE t1, t2; +# +# The same comparison is reached from a correlated subquery, which puts +# the value at a different offset in the same key buffer +# +CREATE TABLE t3 (a VARCHAR(256)) ENGINE=InnoDB; +INSERT INTO t3 VALUES (1),(5),(1),(22),(5),(333),(22),(1); +CREATE TABLE t4 (b INT) ENGINE=InnoDB; +INSERT INTO t4 VALUES (2),(3); +FLUSH STATUS; +SELECT a FROM t3 WHERE EXISTS ( SELECT 1 FROM t4 WHERE b <> t3.a ) ORDER BY a; +a +1 +1 +1 +22 +22 +333 +5 +5 +SELECT VARIABLE_NAME, VARIABLE_VALUE > 0 AS reached +FROM information_schema.SESSION_STATUS +WHERE VARIABLE_NAME LIKE 'SUBQUERY_CACHE%' ORDER BY VARIABLE_NAME; +VARIABLE_NAME reached +SUBQUERY_CACHE_HIT 1 +SUBQUERY_CACHE_MISS 1 +DROP TABLE t3, t4; +# +# Aria leaves the same part of a record undefined, so the outer table's +# engine makes no difference to what is compared +# +CREATE TABLE t5 (id INT PRIMARY KEY, a VARCHAR(256)) ENGINE=Aria; +INSERT INTO t5 VALUES (1,'foo'),(2,'bar'),(3,'foo'),(4,'quux'), +(5,'bar'),(6,'foo'),(7,'corge'),(8,'bar'); +CREATE TABLE t6 (x INT) ENGINE=Aria; +INSERT INTO t6 VALUES (1),(2); +FLUSH STATUS; +UPDATE t5, t6 SET id = id + 100 WHERE a IN ( SELECT 'baz' UNION SELECT 'qux' ); +SELECT VARIABLE_NAME, VARIABLE_VALUE > 0 AS reached +FROM information_schema.SESSION_STATUS +WHERE VARIABLE_NAME LIKE 'SUBQUERY_CACHE%' ORDER BY VARIABLE_NAME; +VARIABLE_NAME reached +SUBQUERY_CACHE_HIT 1 +SUBQUERY_CACHE_MISS 1 +SELECT id, a FROM t5 ORDER BY id; +id a +1 foo +2 bar +3 foo +4 quux +5 bar +6 foo +7 corge +8 bar +DROP TABLE t5, t6; diff --git a/mysql-test/main/subquery_cache_varchar_ref_key.test b/mysql-test/main/subquery_cache_varchar_ref_key.test new file mode 100644 index 0000000000000..e5e2108f3e1f7 --- /dev/null +++ b/mysql-test/main/subquery_cache_varchar_ref_key.test @@ -0,0 +1,88 @@ +--source include/have_innodb.inc + +--echo # +--echo # A ref lookup key built over a VARCHAR is a fixed width image, so the +--echo # bytes that follow the value take part in the key comparison that +--echo # decides whether the previous lookup can be reused, although they +--echo # carry no information. A storage engine is free to leave that part of +--echo # a record undefined, and the subquery cache reaches that comparison +--echo # once per row. Only a build with memory instrumentation can fail on +--echo # the read, so each block below also checks that the subquery cache +--echo # counters moved, which is what shows the comparison was reached at +--echo # all. Only whether they moved is checked, because a prepared +--echo # statement executes the subquery twice and doubles them. +--echo # +--echo # The column has to be wider than 255 bytes. A key field always carries +--echo # a two byte length prefix, and the full width copy that leaves the +--echo # bytes after the value in place is taken only when the source field +--echo # agrees on the width of that prefix. A narrower column takes a +--echo # conversion path that writes the value alone. +--echo # + +CREATE TABLE t1 (id INT PRIMARY KEY, a VARCHAR(256)) ENGINE=InnoDB; +INSERT INTO t1 VALUES (1,'foo'),(2,'bar'),(3,'foo'),(4,'quux'), + (5,'bar'),(6,'foo'),(7,'corge'),(8,'bar'); + +CREATE TABLE t2 (x INT) ENGINE=InnoDB; +INSERT INTO t2 VALUES (1),(2); + +--echo # Values of differing length, so that the bytes after a short one are +--echo # whatever the longer row before it left behind + +--echo # The predicate is false for every row, so nothing is updated +FLUSH STATUS; +UPDATE t1, t2 SET id = id + 100 WHERE a IN ( SELECT 'baz' UNION SELECT 'qux' ); +SELECT VARIABLE_NAME, VARIABLE_VALUE > 0 AS reached +FROM information_schema.SESSION_STATUS +WHERE VARIABLE_NAME LIKE 'SUBQUERY_CACHE%' ORDER BY VARIABLE_NAME; +SELECT id, a FROM t1 ORDER BY id; + +--echo # The same lookups with three rows qualifying +FLUSH STATUS; +UPDATE t1, t2 SET id = id + 10 WHERE a IN ( SELECT 'foo' UNION SELECT 'qux' ); +SELECT VARIABLE_NAME, VARIABLE_VALUE > 0 AS reached +FROM information_schema.SESSION_STATUS +WHERE VARIABLE_NAME LIKE 'SUBQUERY_CACHE%' ORDER BY VARIABLE_NAME; +SELECT id, a FROM t1 ORDER BY id; + +DROP TABLE t1, t2; + +--echo # +--echo # The same comparison is reached from a correlated subquery, which puts +--echo # the value at a different offset in the same key buffer +--echo # + +CREATE TABLE t3 (a VARCHAR(256)) ENGINE=InnoDB; +INSERT INTO t3 VALUES (1),(5),(1),(22),(5),(333),(22),(1); + +CREATE TABLE t4 (b INT) ENGINE=InnoDB; +INSERT INTO t4 VALUES (2),(3); + +FLUSH STATUS; +SELECT a FROM t3 WHERE EXISTS ( SELECT 1 FROM t4 WHERE b <> t3.a ) ORDER BY a; +SELECT VARIABLE_NAME, VARIABLE_VALUE > 0 AS reached +FROM information_schema.SESSION_STATUS +WHERE VARIABLE_NAME LIKE 'SUBQUERY_CACHE%' ORDER BY VARIABLE_NAME; + +DROP TABLE t3, t4; + +--echo # +--echo # Aria leaves the same part of a record undefined, so the outer table's +--echo # engine makes no difference to what is compared +--echo # + +CREATE TABLE t5 (id INT PRIMARY KEY, a VARCHAR(256)) ENGINE=Aria; +INSERT INTO t5 VALUES (1,'foo'),(2,'bar'),(3,'foo'),(4,'quux'), + (5,'bar'),(6,'foo'),(7,'corge'),(8,'bar'); + +CREATE TABLE t6 (x INT) ENGINE=Aria; +INSERT INTO t6 VALUES (1),(2); + +FLUSH STATUS; +UPDATE t5, t6 SET id = id + 100 WHERE a IN ( SELECT 'baz' UNION SELECT 'qux' ); +SELECT VARIABLE_NAME, VARIABLE_VALUE > 0 AS reached +FROM information_schema.SESSION_STATUS +WHERE VARIABLE_NAME LIKE 'SUBQUERY_CACHE%' ORDER BY VARIABLE_NAME; +SELECT id, a FROM t5 ORDER BY id; + +DROP TABLE t5, t6; diff --git a/sql/sql_select.h b/sql/sql_select.h index d03edab058186..7c34664cfd70e 100644 --- a/sql/sql_select.h +++ b/sql/sql_select.h @@ -1986,6 +1986,20 @@ class store_key :public Sql_alloc do_narrow.stop(); + /* + cmp_buffer_with_ref() compares the key buffer as a whole, so the + bytes following a variable length value take part in the comparison + even though they hold no information. Copying the value leaves them + as the source row had them, and a storage engine is free to leave + that part of a record undefined. Their content only decides whether + an already fetched row is fetched again, so mark them defined. + + to_field describes the key part rather than the column: + copy_keys_from_share() narrows the field of a key part built over a + prefix, so this marks the slot in the key buffer and nothing past it. + */ + to_field->mark_unused_memory_as_defined(); + thd->count_cuted_fields= org_count_cuted_fields; return result; }