MySQL mysqldump, hostile SHOW TABLES stack overflow
Default mysqldump copies SHOW TABLES names with no NAME_LEN cap. A hostile MySQL protocol server crashes the dump UID. Labbed 26.7.0 SIGSEGV. Crash witness. No CVE yet.
- Name
- MySQL mysqldump, hostile SHOW TABLES stack overflow
- 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). Victim runs default mysqldump against an attacker MySQL. A real mysqld will not emit identifiers longer than NAME_LEN.
- Published
- 06 Oct 2026
- Updated
- 06 Oct 2026
- Tags
- 0-day, mysql, mysqldump, cwe-120, cwe-121, stack-overflow
SHOW TABLES is not a table
I am @abraxas_null. The proof of concept is on GitHub: abraxas/mysql-mysqldump-show-tables-overflow (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. No CVE yet. CWE-120 / CWE-121. 8.8 High (CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H). Victim runs the default dump client against a host they think is their server. Lab is a crash. Instruction-pointer control is not in the pack.
mysqldump has been copying table names out of SHOW TABLES for longer than some of the people who still run it have been alive. The server is supposed to send identifiers. Identifiers are supposed to fit in NAME_LEN. The client never checks. The protocol does not either, once you stop talking to a real mysqld.
Why MySQL
I had just finished an unpublished Crit/High hunt of Apache Kafka 4.3.1. Default KRaft broker was clean. The leftover that survived was a Windows Connect launcher that never got a Unix CLASSPATH exclude. Before that: Grafana, Prometheus, PostgreSQL. Default daemons came back empty. The interesting leftovers were client tools and config-gated corners.
MySQL is the other SQL server people actually run. The Community tree on GitHub is the whole product: sql/, plugin/, in-tree Router, NDB if you ask cmake, client binaries next to the daemon. Oracle's CPU cadence is a skip list with its own weather. A leftover of a published Server row is a waste of a week. A leftover in mysqldump is the same victim model as PostgreSQL pg_basebackup following a hostile path: the operator pointed a backup client at a name.
I only wanted Critical or High. Local is in-scope. Authenticated SQL DoS is Oracle's usual CPU row and below this bar. FILE privilege INTO OUTFILE is intended. Empty root on a fresh init is intended. Superuser INSTALL PLUGIN inside plugin_dir is the intended RCE analog to Postgres superuser. Enterprise-only plugins are a different product. Connectors, Shell, Workbench, and NDB Operator are out of this tree. Router is in it.
Pinning 26.7.0
Oracle moved Innovation onto calendar versioning. The number looks like a year. It is a year. 26.7.0 shipped 28 July 2026 as the full Community GA Innovation, lineage from 9.7. MYSQL_VERSION in the tree says so.
26.10.0 is Early Access. I skipped it. 26.7.1, 8.4.12, and 9.7.3 are Docker-only August 2026 CPU images. I skipped those too. GitHub has no Releases page that helps. Tags named mysql-26.7.0 and mysql-cluster-26.7.0 share SHA 06a5c1c99c377fc41b2eba1ea244e8b220bdc3c8. That is the pin. Shallow clone, 59 thousand files, one commit.
July 2026 CPU Server ranges stop at 8.4.10 / 9.7.1. This pin is post-July. August 2026 Community advisory has no Server rows. Cluster, Shell, Connectors. The daemon I was about to read was supposed to already have the High leftovers of that summer.
The skip list is half the hunt
dev.mysql.com/community/security/advisories is the public list. July 2026 High Server rows, parent-verified closed on 26.7.0:
- CVE-2026-60315 X Plugin unauth. Compression is installed on the decoder after auth. Unauth CapabilitiesGet is the documented fingerprint.
- CVE-2026-60316 X Plugin high-priv takeover. CRUD runs as the requester. The
mysql.sessionswitch is auth-only. - CVE-2026-60163 Group Replication local XCom
client_msg. External cargo rejectsapp_typebeforeprocess_client_msg. Default stack is MYSQL plusssl_mode=REQUIRED. - CVE-2026-61094 Replication client BINLOG. Gated.
- CVE-2026-60178 / CVE-2026-60183 Clone path and index.
- CVE-2026-46863 Connection-handling connect-attrs. Length-checked in
sql_authentication.cc.
Router HTTP High rows from the same season are closed too. GET-only REST, body and connection caps, Content-Length plus Transfer-Encoding is 400. Default mysqld does not start Router. Configurator CVE-2026-60181 is a Windows binary out of this tree.
Optimizer, JSON, GIS, InnoDB hang/crash CPU rows are PR:L and PR:H DoS. I left them. JSON Duality 60194/60195 are 4.9 DoS on 9.7.0-9.7.1. Different class.
Default mysqld is a handshake, then a wall
Unauthenticated protocol, then acl_authenticate, then do_command. caching_sha2_password is the default plugin. local_infile defaults false. The handshake still advertises CLIENT_LOCAL_FILES; handle_local_infile rejects unless the option is on. Historic default-on client file steal is not 26.7.0.
X Plugin binds *:33060 and still authenticates. That bind is documented. admin_address is null. Community thread_pool ships MODULE_ONLY with PLUGIN_OPT_NO_INSTALL and is not loaded. activate_all_roles_on_login is false. Default file:// INSTALL COMPONENT is forced under plugin_dir. require_secure_transport defaults false, which is a documented compatibility choice, not a leftover.
I split the tree into thirty-one hunts. ACL and roles. Auth plugins. Connection handling. X Plugin. FILE and LOAD. Replication. Group Replication. Clone. Stored DEFINER. UDF and components. Thread pool. Keyring. TLS including the new PQC bits. JSON, GIS, duality. InnoDB paths. Windows configurator. Client tools. Router HTTP. Router MRS and JIT. Change Stream Applier. Information schema. Federated and archive. Classic protocol. NDB storage. Telemetry. Bootstrap and init-file. Optimizer privilege skip. Event scheduler. Binlog encryption. Parser. Admin port.
Most of that is MySQL doing what MySQL said it would do. GRANT still needs the bits plus GRANT OPTION. SYSTEM_USER is checked on user DDL. INSTALL PLUGIN rejects a slash in the SONAME and prepends plugin_dir. JSON_EXTRACT stays in-document. JSON Schema remote $ref is rejected. FEDERATED is default-off. NDB is default-off. Event scheduler running as definer is the documented model.
The default daemon unpublished Crit/High hunt is NONE. I am not going to dress up a CPU DoS as a new Server RCE.
Then I opened mysqldump
Client tools are the other half of a database product. mysqldump is the backup people actually type. --opt is on. --lock-tables is on. mysql_list_tables sends show tables. getTableName returns row[0] with no length cap.
static char *getTableName(int reset) {
static MYSQL_RES *res = nullptr;
MYSQL_ROW row;
if (!res) {
if (!(res = mysql_list_tables(mysql, NullS))) return (nullptr);
}
if ((row = mysql_fetch_row(res))) return ((char *)row[0]);dump_all_tables_in_db builds a stack key db.tablename and copies the table name with my_stpcpy:
char hash_key[2 * NAME_LEN + 2]; /* "db.tablename" */
afterdot = my_stpcpy(hash_key, database);
*afterdot++ = '.';
/* ... */
char *end = my_stpcpy(afterdot, table);NAME_LEN is 64 * 3 = 192. The buffer is 386 bytes. A database named testdb plus a dot leaves 381 bytes for the table. A 400-byte name walks off the end. quote_name is worse in spirit: it doubles quote characters with no bound at all.
static char *quote_name(char *name, char *buff, bool force) {
char *to = buff;
const char qtype = ansi_quotes_mode ? '"' : '`';
if (!force && !opt_quoted && !test_if_special_chars(name)) return name;
*to++ = qtype;
while (*name) {
if (*name == qtype) *to++ = qtype;
*to++ = *name++;
}The lock-tables loop quotes into table_buff[NAME_LEN*2+3] (387 bytes). dump_table quotes into table_buff[NAME_LEN+3] (195 bytes). A name of backticks doubles. The dump UID is a C process that believed the server.
MariaDB's dump binary has this quote_name shape too. Different tree, different binary, same habit: the identifier came from the server, so it must be an identifier. I did not re-file MariaDB. I labbed the MySQL copy.
A real mysqld will not help you
That is the first trap. NAME_CHAR_LEN is 64. The server parser will not create a table named 193 characters of A. You can sit in front of mysql:26.7.0 for a long time issuing CREATE TABLE and learn nothing except that identifiers are identifiers.
The leftover is the client. The oracle is a hostile protocol server: handshake, mysql_native_password, OK on SET, empty result sets on the SHOW VARIABLES and Information Schema probes mysqldump fires before it cares about tables, then one show tables row of chosen length.
I wrote that stub in Python. Protocol 41 greeting. Server version string 26.7.0 so mysql_get_server_version is happy. No CLIENT_SSL, so dump PREFERRED stays cleartext. COM_INIT_DB is OK. Unknown SELECT against performance_schema.column_masking_policy and INFORMATION_SCHEMA.FILES returns an empty set, not an error, because an error here aborts the dump before SHOW TABLES. That last sentence took a few tries.
The lab
Host is macOS. Two containers. Compose project mysql-mysqldump-show-tables-overflow. Loopback only.
stub is python:3.12-slim running stub.py. Internal 3306 is control (table name t). Internal 3307 is overflow (64KiB of A, the witness string, and extra backticks for quote_name doubling). Published 127.0.0.1:18600 and 127.0.0.1:18601.
dump is official mysql:26.7.0. sleep infinity. mysqldump --version is mysqldump Ver 26.7.0 for Linux on aarch64 (MySQL Community Server - GPL). Same compose network. The client talks to hostname stub. --ssl-mode=DISABLED. --protocol=TCP. Empty password. Database testdb. Default flags. No --skip-lock-tables.
The PoC runs control, then overflow, classifies exit codes, greps stub logs for show tables, and refuses SUCCESS unless control lived and overflow died.
Control: handshake, session SETs, empty probes, show tables, LOCK TABLES \t` READ, then SQL 1146 on show table status like 't'`. Exit 2. Dump header says Distrib 26.7.0. No fatal signal.
Overflow: same setup through show tables. SIGSEGV, exit 139. The stub never saw LOCK TABLES on that connection. The process died in the first loop over table names.
Last run:
mysqldump-version ... mysqldump Ver 26.7.0 for Linux on aarch64
control-rc=2 control-signal=0 crash=False
overflow-rc=139 overflow-signal=SIGSEGV crash=True
saw-show-tables=True
SUCCESS mysql-mysqldump-show-tables-overflow control-signal=0 overflow-signal=SIGSEGV dump=26.7.0 image=mysql:26.7.0 control-rc=2 overflow-rc=139 show-tables=True MYSQL-DUMP-SHOW-TABLES-OVERFLOW-WITNESSReplay:
git clone https://github.com/abraxas/mysql-mysqldump-show-tables-overflow
cd mysql-mysqldump-show-tables-overflow/lab
./run.shBind it to loopback. The loopback client is lab/run.sh with a banner on it. Crash witness. No RIP payload. No heap grooming. The oracle is a signal.
Wrong turns, in the order I made them
Pinning Early Access. 26.10.0 looks like "latest." It is EA. Docker-only 26.7.1 looks like a CPU. It is an image tag without a matching full source tree I wanted to hunt. GA Innovation is 26.7.0.
Assuming GitHub Releases. get_latest_release is 404. Tags exist. mysql-cluster-* clutters page one. The Community tag is still there.
Treating empty root as a finding. Fresh init with no password is install-time. --skip-grant-tables is local admin. FILE INTO OUTFILE is FILE. I wrote those on the skip list so I would not "discover" them on day two.
Treating unauth X Plugin hello as High. It binds *:33060. It answers CapabilitiesGet. That is the documented fingerprint. Compression stays off until after auth. CVE-2026-60315 is closed here.
Treating Group Replication as still 60163. Default communication stack is MYSQL. The XCom app_type path is filtered. Config-gated XCOM-stack membership ops are an operator choice of a deprecated stack.
Using a real mysqld as the SHOW TABLES oracle. Identifiers cap at 64 characters. The server is doing its job. The client is not. The lab has to be a stub.
A 4KiB name. dump_all_tables_in_db also has bool real_columns[MAX_FIELDS] on the same frame as hash_key[386]. MAX_FIELDS is 4096. A few kilobytes of overflow land in a boolean array the lock-tables loop has not used yet. No signal. I almost marked FAIL and went looking for a different bug. 64KiB walks off that frame. The SUCCESS line is 64KiB on purpose.
Compiling mysqldump with ASan from the full server tree. cmake of 26.7.0 is a day if you let it be. The official image already has stack memory. SIGSEGV is the witness. ASan would have been a nicer report. It was not required.
Calling the crash instruction-pointer control. MariaDB write-ups of the same quote_name pattern have claimed RIP. I did not reproduce that. The pack is a crash. High is memory corruption in a C client the operator runs against a network server. The lab proves the write.
LOAD DATA LOCAL as the client leftover. CMake ENABLED_LOCAL_INFILE defaults OFF. mysql --local-infile defaults 0. Server local_infile defaults false. Handshake still ORs the capability bit. The handler still rejects. Historic class, closed on default.
Dump restore system / tee / pager. --commands defaults off in batch. mysql < dump.sql does not run client-side shell. Interactive paste is a different bar.
SSL PREFERRED. Documented. --ssl-ca promotes to VERIFY_CA. Not this leftover.
Wine, or claiming the daemon is this bug. mysqld is not mysqldump. Official mysql:26.7.0 is the dump binary I needed. The stub is the hostile server. Default daemon hunt is still NONE.
What an attacker can do
Run a MySQL-protocol listener. Complete the handshake. Speak enough of the session setup that mysqldump testdb reaches show tables. Return one column, one row, a name tens of kilobytes long.
The operator who pointed the default dump client at that host overflows a stack buffer in their mysqldump process. Lab on 26.7.0 is SIGSEGV. The dump UID crashes. Files the dump had open are left as they were. Reliable code execution in that process is a separate, uglier argument and is not in this repository.
Same UI:R as every backup client that trusts a remote server's filenames. Cron that dumps a host from an inventory. A restore laptop. A CI job that mysqldumps a staging box whose DNS you do not own this week.
--lock-tables is default. The first loop is enough. You do not need --tab. You do not need --raw. You need the operator to run mysqldump.
The fix
Cap getTableName at NAME_LEN. If SHOW TABLES sent more than that, it is not a table. Give quote_name a destination length and refuse to write past it. Replace my_stpcpy into hash_key with a bounded copy. The identifier limit already exists. Use it on the way out of the protocol, not only on the way into the parser.
Until then, do not point mysqldump at a MySQL you do not already trust with the dump UID. TLS with VERIFY_IDENTITY does not fix a stack copy. It does stop you from talking to a random listener that borrowed the name.
I am not going to print a hostile greeting you can paste at someone else's laptop. The missing bound is the useful part. If you own the client, run the proof of concept against loopback.
References
- Proof of concept: abraxas/mysql-mysqldump-show-tables-overflow · mysql-mysqldump-show-tables-overflow-Abraxas-Labs.py
- Lab:
lab/· Dockerfile · docker-compose.yml · run.sh · poc.py · stub.py - @abraxas_null · github.com/abraxas · abraxaslabs.tech · abraxas.null@proton.me
- CWE-120 · CWE-121
- Tree: mysql/mysql-server mysql-26.7.0 (
06a5c1c99c377fc41b2eba1ea244e8b220bdc3c8) ·mysqldump.cc·mysql_com.h·mysql_list_tables - Community advisories: dev.mysql.com/community/security/advisories