Bug #120467 dd_open_fk_tables function heap-use-after-free
Submitted: 13 May 15:57
Reporter: youxiang mao Email Updates:
Status: Open Impact on me:
None 
Category:MySQL Server: InnoDB storage engine Severity:S3 (Non-critical)
Version:8.0.45 OS:Any
Assigned to: CPU Architecture:Any

[13 May 15:57] youxiang mao
Description:
Bug: UAF in dd_open_fk_tables when a table is evicted from dict cache.
I run test case in 8.0.45, but the same issue persists in the latest version.
while its heap still contains FK child table names stored in fk_list.
when two operations are replayed concurrently:
  - Thread 1 opens table ta and traverses
    its FK chain via dd_open_fk_tables.  Inside dd_table_check_for_child,
    child-table names are allocated in the *parent* table's heap and
    pushed into fk_list (dict_names_t).  After tc is opened and closed
    (ref_count -> 0), td's name pointer still lives inside tc->heap.
  - Thread 2 which evicts table tc and frees tc->heap.
Thread 1 then dereferences the now-dangling td name pointer -> UAF.

Error message like this:
MySQL Version 8.0.45

==35954==ERROR: AddressSanitizer: heap-use-after-free on address 0x61d000624ae0 at pc 0x7fa670e913be bp 0x7fa64b47b2e0 sp 0x7fa64b47aa88
READ of size 8 at 0x61d000624ae0 thread T47 (connection)
    #0 0x7fa670e913bd  (/lib64/libasan.so.4+0x513bd)
    #1 0x7fa66f277be8 in std::basic_ostream<char, std::char_traits<char> >& std::operator<< <std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*) (/lib64/libstdc++.so.6+0x96be8)
    #2 0x5c556f9 in ib::logger& ib::logger::operator<< <char*>(char* const&) /disk2/myx/mysql-server/storage/innobase/include/ut0log.h:70
    #3 0x6426b2f in dd_open_fk_tables(std::deque<char const*, ut::allocator<char const*, ut::detail::allocator_base_pfs<char const*> > >&, bool, THD*) /disk2/myx/mysql-server/storage/innobase/dict/dict0dd.cc:5394
    #4 0x64539b4 in dict_table_t* dd_open_table<dd::Table>(dd::cache::Dictionary_client*, TABLE const*, char const*, dd::Table const*, THD*) /disk2/myx/mysql-server/storage/innobase/dict/dict0dd.cc:5422
    #5 0x5c33461 in ha_innobase::open(char const*, int, unsigned int, dd::Table const*) /disk2/myx/mysql-server/storage/innobase/handler/ha_innodb.cc:7289
    #6 0x3a43627 in handler::ha_open(TABLE*, char const*, int, int, dd::Table const*) /disk2/myx/mysql-server/sql/handler.cc:2790
    #7 0x368a156 in open_table_from_share(THD*, TABLE_SHARE*, char const*, unsigned int, unsigned int, unsigned int, TABLE*, bool, dd::Table const*) /disk2/myx/mysql-server/sql/table.cc:3212
    #8 0x31da12f in open_table(THD*, Table_ref*, Open_table_context*) /disk2/myx/mysql-server/sql/sql_base.cc:3386
    #9 0x31db355 in open_and_process_table /disk2/myx/mysql-server/sql/sql_base.cc:5053
    #10 0x31dc0a9 in open_tables(THD*, Table_ref**, unsigned int*, unsigned int, Prelocking_strategy*) /disk2/myx/mysql-server/sql/sql_base.cc:5858
    #11 0x31df022 in open_tables_for_query(THD*, Table_ref*, unsigned int) /disk2/myx/mysql-server/sql/sql_base.cc:6747

0x61d000624ae0 is located 608 bytes inside of 1960-byte region [0x61d000624880,0x61d000625028)
freed by thread T48 (connection) here:
    #0 0x7fa670f1e508 in __interceptor_free (/lib64/libasan.so.4+0xde508)
    #1 0x5c4ca5d in ut::detail::free(void*) /disk2/myx/mysql-server/storage/innobase/include/detail/ut/allocator_traits.h:77
    #2 0x5c4ca89 in ut::detail::Alloc_fn::free(void*) /disk2/myx/mysql-server/storage/innobase/include/detail/ut/allocator_traits.h:105
    #3 0x5c4cd8e in ut::detail::Alloc_pfs::free(void*) /disk2/myx/mysql-server/storage/innobase/include/detail/ut/alloc.h:381
    #4 0x5c53c3d in ut::detail::Alloc_<ut::detail::Alloc_pfs>::free(void*) /disk2/myx/mysql-server/storage/innobase/include/detail/ut/alloc.h:456
    #5 0x5c53c48 in ut::free(void*) /disk2/myx/mysql-server/storage/innobase/include/ut0new.h:721
    #6 0x61c272e in mem_heap_block_free(mem_block_info_t*, mem_block_info_t*) /disk2/myx/mysql-server/storage/innobase/mem/memory.cc:440
    #7 0x61bf4d8 in mem_heap_free /disk2/myx/mysql-server/storage/innobase/include/mem0mem.ic:465
    #8 0x61bf942 in dict_mem_table_free(dict_table_t*) /disk2/myx/mysql-server/storage/innobase/dict/mem.cc:147
    #9 0x63e4224 in dict_table_remove_from_cache_low /disk2/myx/mysql-server/storage/innobase/dict/dict0dict.cc:1970
    #10 0x63e4b67 in dict_table_remove_from_cache_debug(dict_table_t*, bool) /disk2/myx/mysql-server/storage/innobase/dict/dict0dict.cc:2022
    #11 0x5c3b83b in innodb_internal_table_validate /disk2/myx/mysql-server/storage/innobase/handler/ha_innodb.cc:20659
    #12 0x340b8b0 in sys_var_pluginvar::do_check(THD*, set_var*) /disk2/myx/mysql-server/sql/sql_plugin_var.cc:333

How to repeat:
mtr case:
CREATE TABLE ta (id INT PRIMARY KEY) ENGINE = InnoDB;
CREATE TABLE tb (
  id  INT PRIMARY KEY,
  aid INT,
  CONSTRAINT fk_tb_ta FOREIGN KEY (aid) REFERENCES ta (id)
) ENGINE = InnoDB;
CREATE TABLE tc (
  id  INT PRIMARY KEY,
  bid INT,
  CONSTRAINT fk_tc_tb FOREIGN KEY (bid) REFERENCES tb (id)
) ENGINE = InnoDB;
CREATE TABLE td (
  id  INT PRIMARY KEY,
  cid INT,
  CONSTRAINT fk_td_tc FOREIGN KEY (cid) REFERENCES tc (id)
) ENGINE = InnoDB;

--echo #evict all table cache
SET GLOBAL debug="+d,innodb_evict_autoinc_table";

--echo # Evict table from dictionary cache
--error ER_WRONG_VALUE_FOR_VAR
SET GLOBAL innodb_ft_aux_table="test/ta";

--error ER_WRONG_VALUE_FOR_VAR
SET GLOBAL innodb_ft_aux_table="test/tb";

--error ER_WRONG_VALUE_FOR_VAR
SET GLOBAL innodb_ft_aux_table="test/tc";

--error ER_WRONG_VALUE_FOR_VAR
SET GLOBAL innodb_ft_aux_table="test/td";

SET GLOBAL debug="-d,innodb_evict_autoinc_table";

--send select * from ta

--connect (con1,localhost,root,,)
--connection con1
SET GLOBAL DEBUG = '+d,dd_open_fk_tables_sync';

--echo # --- iteration 1 (tb) ---
SET DEBUG_SYNC = 'now WAIT_FOR fk_at_sync';
SET DEBUG_SYNC = 'now SIGNAL fk_continue';
 
--echo # --- iteration 2 (tc) ---
SET DEBUG_SYNC = 'now WAIT_FOR fk_at_sync';
SET DEBUG_SYNC = 'now SIGNAL fk_continue';
 
--echo # --- iteration 3 (td) ---
# con1 holds the td name pointer (inside tc->heap) but has not yet
# called dd_open_table_one_on_name for td.
# Without the fix the td name pointer becomes dangling.
SET DEBUG_SYNC = 'now WAIT_FOR fk_at_sync';

--echo #evict tc table cache
SET GLOBAL debug="+d,innodb_evict_autoinc_table";
--error ER_WRONG_VALUE_FOR_VAR
SET GLOBAL innodb_ft_aux_table="test/tc";
SET GLOBAL debug="-d,innodb_evict_autoinc_table";

SET DEBUG_SYNC = 'now SIGNAL fk_continue';

# With the bug this crashes or triggers
# an ASAN heap-use-after-free; with the fix it succeeds.
connection default;
--reap

SET GLOBAL DEBUG = '-d,dd_open_fk_tables_sync';
DROP TABLE td;
DROP TABLE tc;
DROP TABLE tb;
DROP TABLE ta;

disconnect con1;

Suggested fix:
diff --git a/storage/innobase/dict/dict0dd.cc b/storage/innobase/dict/dict0dd.cc
index b87e4963e51..4dd504a530b 100644
--- a/storage/innobase/dict/dict0dd.cc
+++ b/storage/innobase/dict/dict0dd.cc
@@ -4572,13 +4572,7 @@ dberr_t dd_table_check_for_child(dd::cache::Dictionary_client *client,
         the child table name is saved here. The child
         table will be loaded later, along with its
         foreign key constraint. */
-        lint old_size = mem_heap_get_size(m_table->heap);
-
-        fk_tables->push_back(
-            mem_heap_strdupl(m_table->heap, full_name, strlen(full_name)));
-
-        lint new_size = mem_heap_get_size(m_table->heap);
-        dict_sys->size += new_size - old_size;
+        fk_tables->emplace_back(full_name);
       }

       dict_sys_mutex_exit();
@@ -5374,19 +5368,24 @@ func_exit:
 @param[in]      thd             thread THD */
 void dd_open_fk_tables(dict_names_t &fk_list, bool dict_locked, THD *thd) {
   while (!fk_list.empty()) {
-    char *name = const_cast<char *>(fk_list.front());
+    std::string name = std::move(fk_list.front());

     if (innobase_get_lower_case_table_names() == 2) {
-      innobase_casedn_str(name);
+      innobase_casedn_str(&name[0]);
     } else {
 #ifndef _WIN32
       if (innobase_get_lower_case_table_names() == 1) {
-        innobase_casedn_str(name);
+        innobase_casedn_str(&name[0]);
       }
 #endif /* !_WIN32 */
     }

-    dd_open_table_one_on_name(name, dict_locked, fk_list, thd);
+    DBUG_EXECUTE_IF("dd_open_fk_tables_sync", {
+      const char act[] = "now SIGNAL fk_at_sync WAIT_FOR fk_continue";
+      assert(!debug_sync_set_action(thd, STRING_WITH_LEN(act)));
+    });
+
+    dd_open_table_one_on_name(name.c_str(), dict_locked, fk_list, thd);

     fk_list.pop_front();
   }
diff --git a/storage/innobase/dict/dict0load.cc b/storage/innobase/dict/dict0load.cc
index bec321164a4..a407013e8e1 100644
--- a/storage/innobase/dict/dict0load.cc
+++ b/storage/innobase/dict/dict0load.cc
@@ -2182,7 +2182,6 @@ dict_table_t *dict_load_table(const char *name, bool cached,
                               dict_err_ignore_t ignore_err,
                               const std::string *prev_table) {
   dict_names_t fk_list;
-  dict_names_t::iterator i;
   table_name_t table_name;
   dict_table_t *result;

@@ -2207,8 +2206,8 @@ dict_table_t *dict_load_table(const char *name, bool cached,
     while (!fk_list.empty()) {
       table_name_t fk_table_name;
       dict_table_t *fk_table;
-
-      fk_table_name.m_name = const_cast<char *>(fk_list.front());
+      std::string fk_name = std::move(fk_list.front());
+      fk_table_name.m_name = const_cast<char *>(fk_name.c_str());
       fk_table = dict_table_check_if_in_cache_low(fk_table_name.m_name);
       if (!fk_table) {
         dict_load_table_one(fk_table_name, cached, ignore_err, fk_list,
@@ -2876,16 +2875,9 @@ stack. */
     here.  The child table will be loaded later, along with its
     foreign key constraint. */

-    lint old_size = mem_heap_get_size(ref_table->heap);
-
     ut_a(ref_table != nullptr);
-    fk_tables.push_back(mem_heap_strdupl(ref_table->heap,
-                                         foreign->foreign_table_name_lookup,
-                                         foreign_table_name_len));
-
-    lint new_size = mem_heap_get_size(ref_table->heap);
-    dict_sys->size += new_size - old_size;
-
+    fk_tables.emplace_back(foreign->foreign_table_name_lookup,
+                           foreign_table_name_len);
     dict_foreign_remove_from_cache(foreign);
     return DB_SUCCESS;
   }
diff --git a/storage/innobase/include/dict0load.h b/storage/innobase/include/dict0load.h
index 533825ff8dd..631f5279e52 100644
--- a/storage/innobase/include/dict0load.h
+++ b/storage/innobase/include/dict0load.h
@@ -47,7 +47,7 @@ this program; if not, write to the Free Software Foundation, Inc.,
 #include <deque>

 /** A stack of table names related through foreign key constraints */
-typedef std::deque<const char *, ut::allocator<const char *>> dict_names_t;
+typedef std::deque<std::string> dict_names_t;

 /** enum that defines all system table IDs. @see SYSTEM_TABLE_NAME[] */
 enum dict_system_id_t {
diff --git a/storage/innobase/row/row0mysql.cc b/storage/innobase/row/row0mysql.cc
index 919d2db1d7a..cec37b68224 100644
--- a/storage/innobase/row/row0mysql.cc
+++ b/storage/innobase/row/row0mysql.cc
@@ -4292,7 +4292,7 @@ dberr_t row_rename_table_for_mysql(const char *old_name, const char *new_name,
       if (is_rename) {
         /* Ensure that old renamed table names are not in this list. */
         for (auto it = fk_tables.begin(); it != fk_tables.end();) {
-          if (strcmp(*it, old_name) == 0) {
+          if (*it == old_name) {
             it = fk_tables.erase(it);
           } else {
             ++it;