Research/mysql-mysqldump-tab-path
0-dayNo CVEHighPublic

MySQL mysqldump --tab, hostile SHOW TABLES path

mysqldump --tab=DIR writes client .sql files with fn_format and no MY_REPLACE_DIR. A hostile SHOW TABLES name with a directory ignores DIR. Labbed 26.7.0. No CVE yet.

Name
MySQL mysqldump --tab, hostile SHOW TABLES path
Type
0-day analysis
CVE
n/a
CVE Risk
high
Disclosure Status
public
Vendor
Oracle MySQL
Affected
MySQL Community Server mysqldump 26.7.0 (06a5c1c) with --tab. Victim runs the dump client against an attacker MySQL. A real mysqld will not emit identifiers with slashes.
Published
06 Oct 2026
Updated
06 Oct 2026
Tags
0-day, mysql, mysqldump, cwe-22, path-traversal

DIR was a suggestion

I am @abraxas_null. The proof of concept is on GitHub: abraxas/mysql-mysqldump-tab-path (loopback client). The lab stack is lab/: Dockerfile, docker-compose.yml, run.sh, poc.py, stub.py. Authorized lab only. It talks to loopback.

This is MySQL Community Server 26.7.0 (06a5c1c99c377fc41b2eba1ea244e8b220bdc3c8) mysqldump --tab. No CVE yet. CWE-22. 8.1 High (CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:N). Victim runs the documented backup-to-files path against a host they think is theirs. Lab is a witness .sql outside DIR.

--tab=DIR is supposed to mean DIR. The man page even warns that this only works if you run the dump on the same machine as the server, because the .txt files go through SELECT ... INTO OUTFILE. That sentence is about FILE privilege and secure_file_priv. The .sql files are written by the client. The client asked fn_format to put them in DIR. fn_format looked at the table name, saw a directory already in it, and kept that one.

Why this leftover, after the crash

I had just published mysqldump SHOW TABLES stack overflow. Same pin, same hunt, same rude fact: getTableName returns whatever SHOW TABLES sent. A 64KiB name SIGSEGVs mysqldump 26.7.0. A short name with a slash is a different sink.

Default mysqld on 26.7.0 was empty for unpublished Crit/High. July 2026 CPU High Server rows are closed. Client tools were the class that survived, same victim model as PostgreSQL pg_basebackup following a hostile path. Overflow was G1. --tab was sitting one function later in the same file.

I only wanted Critical or High. --tab is not default. It is the documented way to dump one file per table. Operators type it on purpose. That is enough UI:R.

Thirty-one hunts on this pin went through ACL, auth plugins, connection handling, X Plugin leftovers of 60315/60316, FILE/LOAD, replication, group replication, clone, stored DEFINER, UDF and components, Community thread pool, keyring, TLS including PQC, JSON/GIS/duality, InnoDB paths, Windows configurator, Router HTTP, Router MRS, Change Stream Applier, I_S, federated, classic protocol, NDB, telemetry, bootstrap, optimizer privilege skip, event scheduler, binlog encryption, parser, admin port. Default mysqld unpublished Crit/High is still NONE. GRANT still needs GRANT OPTION. INSTALL PLUGIN still rejects a slash and prepends plugin_dir. X Plugin still authenticates. Group Replication's default MYSQL stack still rejects external app_type. The interesting leftovers were the clients that sit next to the daemon in the same tarball and believe SHOW TABLES.

Pinning 26.7.0, still

Oracle Innovation is calendar versioning. 26.7.0 is the full Community GA from 28 July 2026, lineage from 9.7. 26.10.0 is Early Access. 26.7.1 is a Docker-only August CPU image. GitHub tags mysql-26.7.0 and mysql-cluster-26.7.0 share 06a5c1c99c377fc41b2eba1ea244e8b220bdc3c8. I did not re-hunt the tree. I labbed the next leftover.

Skip list is the same Community advisories page. X Plugin 60315/60316, Group Replication 60163, replication 61094, clone 60178/60183, connect-attrs 46863: closed. FILE INTO OUTFILE with FILE is intended. Empty root is install-time. I am not re-filing those.

MariaDB CVE-2025-13699 is mariadb-dump view-name traversal (ZDI-25-1025). Same class. Different binary. This pack is the MySQL Community copy of "the table name is a path."

--tab is two writers pretending to be one

mysqldump -T DIR testdb does two things.

The server runs SELECT ... INTO OUTFILE 'DIR/table.txt'. That path is also built with fn_format without MY_REPLACE_DIR (mysqldump.cc:4272-4278). Then it is sent to the server as SQL. FILE privilege, secure_file_priv, same-host. Intended, if ugly.

The client opens DIR/table.sql and writes CREATE TABLE. That is open_sql_file_for_table:

C
static FILE *open_sql_file_for_table(const char *table, int flags) {
  FILE *res;
  char filename[FN_REFLEN], tmp_path[FN_REFLEN];
  convert_dirname(tmp_path, path, NullS);
  res = my_fopen(fn_format(filename, table, tmp_path, ".sql",
                           MYF(MY_UNPACK_FILENAME | MY_APPEND_EXT)),
                 flags, MYF(MY_WME));
  return res;
}

path is --tab. table is the raw SHOW TABLES string. Flags are MY_UNPACK_FILENAME | MY_APPEND_EXT. There is no MY_REPLACE_DIR.

fn_format in mysys/mf_format.cc:

C
size_t length = dirname_part(dev, name, &dev_length);
name += length;
if (length == 0 || (flag & MY_REPLACE_DIR)) {
  convert_dirname(dev, dir, NullS); /* Use given directory */
}

If the table name already has a directory, DIR loses. my_fopen as the dump UID. Append .sql. Done.

open_sql_file_for_table is called from get_table_structure after SHOW CREATE TABLE succeeds (mysqldump.cc:3260-3270). A stub that 1146s SHOW TABLE STATUS never gets here. I learned that on G1's control run, which died at show table status like 't' and never wrote a file. That was the right failure for a crash lab. It is the wrong failure for a path lab.

check_if_ignore_table is the first caller. It sends show table status like %s with quote_for_like. If the query errors (and it is not a parse error), the function assumes the table is fine and does not set table_type. get_table_structure then strcmps that buffer against "VIEW". If you want a file on disk you do not play those games. You return a row. row[1] is Engine. NULL Engine means VIEW, and views take a different CREATE path. I sent InnoDB.

SHOW CREATE is pickier. mysql_fetch_field_direct(result, 0) and strcmp(field->name, "View"). If you name the first column View, mysqldump thinks it has a view, saves the statement, and goes off to build a dummy. The .sql may still open, but the contents are a different story. First column name Table. Second column the CREATE text. Put the witness in a COMMENT= so the written file is the oracle, not just a timestamp.

The stub had to grow up

G1's protocol fake was a handshake, OK on SET, empty SHOW VARIABLES, one fat SHOW TABLES row, then a shrug. Overflow died in my_stpcpy before LOCK TABLES came back.

G2 needs a short name, then a conversation:

  1. show tables → /work/oracle/MYSQL-DUMP-TAB-TRAVERSAL-WITNESS
  2. LOCK TABLES \/work/oracle/...` READ` → OK
  3. show table status like '/work/oracle/...' → a row whose Engine is InnoDB. NULL Engine is a VIEW. A 1146 is an abort.
  4. SET SQL_QUOTE_SHOW_CREATE=1 → OK
  5. SET SESSION character_set_results = 'binary' → OK
  6. show create table \/work/oracle/...` → two columns, first field name **Table** (not View`), CREATE text that carries the witness in a COMMENT
  7. Then the client opens the .sql. show fields 1146 after that is noise.

I kept the name under NAME_LEN (192). A 64KiB path would have been a G1 re-run with extra steps, and a SIGSEGV with no file. Absolute path, not ../, so the write does not depend on the dump process cwd. Official mysql:26.7.0 WORKDIR is not a contract.

Control table is t. Same stub, other port. --tab=/work/tabdir must still mean tabdir.

The lab

Host is macOS. Two containers. Compose project mysql-mysqldump-tab-path. Loopback only.

stub is python:3.12-slim running stub.py. Internal 3306 is control. Internal 3307 is traversal. Published 127.0.0.1:18610 and 127.0.0.1:18611. G1 used 18600/18601. Different leftover, different ports.

dump is official mysql:26.7.0, user: "0:0", working_dir: /work, volume ./work:/work, sleep infinity. mysqldump --version is mysqldump Ver 26.7.0 for Linux on aarch64 (MySQL Community Server - GPL). --ssl-mode=DISABLED. --protocol=TCP. --tab=/work/tabdir. Empty password. Database testdb.

The PoC wipes *.sql under work/, runs control, checks work/tabdir/t.sql exists and work/oracle/ has no witness file, then runs traversal and checks work/oracle/MYSQL-DUMP-TAB-TRAVERSAL-WITNESS.sql exists, looks like a dump, contains the witness, and was not written under tabdir.

Control: dump header, Table structure for table \t`, DROP TABLE IF EXISTS `t`. Exit 2 on show fields from `t`` 1146. Oracle dir empty.

Traversal: dump header, Table structure for table \/work/oracle/MYSQL-DUMP-TAB-TRAVERSAL-WITNESS`, CREATE with COMMENT='MYSQL-DUMP-TAB-TRAVERSAL-WITNESS'. Same exit 2. Tabdir still only has t.sql` from control. The witness file is next door.

Last run:

Plain text
control-sql-exists=True path=work/tabdir/t.sql
control-oracle-absent=True
traversal-sql-exists=True path=work/oracle/MYSQL-DUMP-TAB-TRAVERSAL-WITNESS.sql
tabdir-witness-hits=[]
SUCCESS mysql-mysqldump-tab-path control-sql=yes traversal-outside=yes dump=26.7.0 image=mysql:26.7.0 MYSQL-DUMP-TAB-TRAVERSAL-WITNESS

Replay:

Plain text
git clone https://github.com/abraxas/mysql-mysqldump-tab-path
cd mysql-mysqldump-tab-path/lab
./run.sh

Bind it to loopback. The loopback client is lab/run.sh with a banner on it. The oracle is a file outside DIR. Not a shell. Not INTO OUTFILE.

Wrong turns, in the order I made them

Calling INTO OUTFILE the leftover. The .txt path is also unsanitized in fn_format. It is then sent to the server. FILE plus secure_file_priv is the intended model. The unpublished High is the client .sql that never asks the server for permission to create a file.

Using a real mysqld. Identifiers do not contain /. CREATE TABLE \/work/oracle/x`is not how you provefn_format`. The oracle is a stub.

Stopping at SHOW TABLE STATUS. G1's stub returned 1146 there so control could exit without a signal. Copying that stub blindly means open_sql_file_for_table never runs. Engine must be a string. First field of SHOW CREATE must be named Table.

A relative ../oracle/WITNESS from an unknown cwd. If the dump binary's cwd is /, you write /oracle/... in the container and miss the volume. Absolute /work/oracle/... is the mount. I used that.

A long name. G1 already crashed on 64KiB. This leftover is a path, not a length. Short name. Different ports. Different compose project.

--skip-lock-tables as a required flag. LOCK TABLES with a weird name still OK'd on the stub. The query is LOCK TABLES \/work/oracle/MYSQL-DUMP-TAB-TRAVERSAL-WITNESS` READ. quote_namewill happily backtick a path.--tab` is the required flag. Default lock-tables stayed on.

SHOW CREATE first column named View. I almost sent a one-column result. mysqldump would have taken the view branch, allocated a dummy, and I would have spent an hour staring at an empty oracle/ blaming fn_format. The field name is the branch. The row text is decoration until that strcmp loses.

Writing the witness only in the table name. The .sql header quotes the path. That is already a strong IOC. Putting COMMENT='MYSQL-DUMP-TAB-TRAVERSAL-WITNESS' in the CREATE text means looks_like_dump() can require both a dump header and the witness string, so a zero-byte fopen is FAIL.

Wine, or claiming the daemon is this bug. mysqld is not mysqldump. --tab is not default mysqldump without -T. Default daemon hunt is still NONE. This leftover is the operator who asked for files.

Treating empty --tab relative to a laptop home directory as the demo. The lab writes under ./work in the run dir. No home paths in the pack.

What an attacker can do

Run a MySQL-protocol listener. Complete the handshake. Speak enough session setup that mysqldump --tab=DIR testdb reaches SHOW TABLES, SHOW TABLE STATUS, and SHOW CREATE TABLE. Return one table name that is a path: /etc/cron.d/x, ../.ssh/authorized_keys if cwd cooperates, /work/wherever.

The dump UID creates or overwrites thatpath.sql with a MySQL dump header and whatever CREATE text you put in SHOW CREATE. You do not get FILE on the server. You get the client process's write access. Cron, shell rc, a unit file the dump user owns, a config next to the backup tree. UI:R: someone has to run --tab.

Same command without --tab never opens these files. G1 without --tab is the overflow: default mysqldump testdb against a long name, SIGSEGV, no file. This pack is the path. Two leftovers, one protocol lie (SHOW TABLES is an identifier), two sinks (my_stpcpy versus fn_format). I published them separately because they disclose separately.

You get the dump UID, not root for free. The .sql is attacker-shaped CREATE text plus a MySQL header, not an arbitrary byte string. It is still a file create/truncate in a location DIR was supposed to confine. If the dump user can write a unit, a cron drop-in, or a shell rc, the next login is the rest of the chain. I did not plant those. The witness is MYSQL-DUMP-TAB-TRAVERSAL-WITNESS.sql next to the directory you asked for.

The fix

fn_format(..., MY_UNPACK_FILENAME | MY_APPEND_EXT | MY_REPLACE_DIR) for --tab client .sql names. Or basename the table before you treat it as a filename. --tab=DIR should mean DIR. Keep rejecting / in identifiers on the server. The client should not trust the protocol more than the parser does.

Until then, do not point mysqldump --tab at a MySQL you do not already trust with the dump UID. TLS with VERIFY_IDENTITY does not fix a path join. It does stop you from talking to a random listener that borrowed the name.

I am not going to print a table name you can paste at someone else's backup job. The missing MY_REPLACE_DIR is the useful part. If you own the client, run the proof of concept against loopback.

References