MySQL mysqlbinlog --raw, hostile Rotate ident path
mysqlbinlog --raw -R copies a Rotate ident into log_file_name with no basename check. The next FORMAT_DESCRIPTION event fopen's that path and writes BINLOG_MAGIC. Labbed 26.7.0. File write, not RCE. No CVE yet.
- Name
- MySQL mysqlbinlog --raw, hostile Rotate ident path
- Type
- 0-day analysis
- CVE
- n/a
- CVE Risk
- high
- Disclosure Status
- public
- Vendor
- Oracle MySQL
- Affected
- MySQL Community Server mysqlbinlog 26.7.0 (06a5c1c) with --raw --read-from-remote-server. Victim points the client at an attacker dump. A real mysqld will not emit a Rotate ident with slashes.
- Published
- 06 Oct 2026
- Updated
- 06 Oct 2026
- Tags
- 0-day, mysql, mysqlbinlog, cwe-22, path-traversal
The ident was already a filename
I am @abraxas_null. The proof of concept is on GitHub: abraxas/mysql-mysqlbinlog-raw-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) mysqlbinlog --raw --read-from-remote-server. 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 remote binlog file copy against a host they think is theirs. Lab is a witness file with BINLOG_MAGIC. File write. Not RCE.
--raw is supposed to mean "write the events as files, named after the binlogs." The server names binlogs. The client copies the name. Nobody takes basename. A Rotate ident with a slash is a path. The next FORMAT_DESCRIPTION event opens it.
Third leftover, same hunt
I had already published mysqldump SHOW TABLES overflow and mysqldump --tab path. Same pin, same thirty-one hunts, same empty default mysqld. Client tools were the class that survived. Overflow was a long SHOW TABLES name. --tab was a table name with a directory. mysqlbinlog --raw is the replica of that thought for binlog idents.
I only wanted Critical or High. --raw is not default. It is the documented way to pull remote binlog files. Operators type -R --raw 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. The interesting leftovers were the clients in the same tarball.
July 2026 CPU High Server rows are closed on this pin. FILE INTO OUTFILE with FILE is intended. Empty root is install-time. LOAD DATA temps in mysqlbinlog stay SQL_LOAD_MB under tmpdir. I left those. The unpublished High is the Rotate ident.
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. GitHub tags mysql-26.7.0 and mysql-cluster-26.7.0 share 06a5c1c99c377fc41b2eba1ea244e8b220bdc3c8. Official image mysql:26.7.0 is the client I needed.
--raw requires --read-from-remote-server (-R) or --read-from-remote-source. -R is BINLOG-DUMP-NON-GTIDS, which is COM_BINLOG_DUMP (0x12). I did not speak GTID dump.
--result-file with --raw is a prefix for the ident. snprintf(log_file_name, "%s%s", output_file, ident). That is a different leftover (prefix plus ident). I labbed the no-prefix path: my_stpcpy(log_file_name, rev->new_log_ident). Ident is the whole filename.
Fake Rotate, real Rotate, then fopen
dump_remote_log_entries connects, SELECT VERSION(), SET @master_binlog_checksum='NONE', then mysql_binlog_open. The source streams events as OK-byte packets.
When the type is ROTATE:
if (raw_mode) {
if (output_file != nullptr) {
snprintf(log_file_name, sizeof(log_file_name), "%s%s", output_file,
rev->new_log_ident);
} else {
my_stpcpy(log_file_name, rev->new_log_ident);
}
}
if (rev->common_header->when.tv_sec == 0) {
if (!to_last_remote_log) {
if ((rev->ident_len != rpl.file_name_length) ||
memcmp(rev->new_log_ident, logname, rpl.file_name_length)) {
return OK_CONTINUE; /* stop. fake Rotate about a different log */
}
continue; /* skip matching fake Rotate. ident already copied */
}
}Timestamp 0 is a fake Rotate. The server sends one at the start of a dump, naming the requested log. If that ident does not match the CLI log name, mysqlbinlog stops. If it matches, the event is skipped, but log_file_name is already the ident.
Timestamp not 0 is a real Rotate. It is in the log, describing the next log. mysqlbinlog copies the ident and keeps going. / is not stripped. .. is not stripped. Length is FN_REFLEN-1. Then a FORMAT_DESCRIPTION event:
if (raw_mode) {
if (result_file && (result_file != stdout))
my_fclose(result_file, MYF(0));
if (!(result_file = my_fopen(
log_file_name, O_WRONLY | MY_FOPEN_BINARY, MYF(MY_WME)))) {
error("Could not create log file '%s'", log_file_name);
return ERROR_STOP;
}
if (my_fwrite(result_file, (const uchar *)BINLOG_MAGIC,
BIN_LOG_HEADER_SIZE, MYF(MY_NABP))) {
error("Could not write into log file '%s'", log_file_name);BINLOG_MAGIC is \xfe\x62\x69\x6e (\xfebin). Then the FD event itself. The file is a tiny binlog. The path is whatever the last Rotate said.
The stub had to speak dump, not SQL
G1 and G2 were SHOW TABLES. G3 is a replica stream. Handshake is the same: protocol 41, mysql_native_password, no SSL, version 26.7.0. Then SELECT VERSION() must return a row. Then the checksum SET must be OK. Then COM_BINLOG_DUMP: position 4 LE, flags 2 LE (BINLOG_DUMP_NON_BLOCK unless --stop-never), server_id 4 LE, filename. mysqlbinlog dumps from position 4, which is BIN_LOG_HEADER_SIZE, so the first real event in a file would be the FD. Events go out as 0x00 plus the v4 event. End with EOF 0xfe. COM_REGISTER_SLAVE is unused on this path. server_id is 0 unless --stop-never.
Rotate is type 4: 19-byte common header, 8-byte next-file position, then ident bytes. Fake uses timestamp 0 and flag LOG_EVENT_ARTIFICIAL_F. Real uses timestamp 1.
FORMAT_DESCRIPTION is type 15. It has to parse. Header length 19, binlog version 4, server version 26.7.0 padded to 50 bytes, post-header length array for the event types 26.7.0 knows. Checksums off, because we set @master_binlog_checksum='NONE'. A junk FD means mysqlbinlog never takes the my_fopen branch. I iterated until it did.
CONTROL stream: fake Rotate ts=0 ident=mysql-bin.000001 (matches CLI), FD, EOF. log_file_name is the basename. fopen writes /work/mysql-bin.000001. Oracle dir empty.
TRAVERSAL stream: matching fake Rotate (so we do not stop), real Rotate ts=1 ident=/work/oracle/MYSQL-BINLOG-RAW-WITNESS, FD, EOF. The real Rotate overwrites log_file_name with the absolute path. FD fopen's it.
Do not send a fake Rotate whose ident is the path. That is "about a different log." The dump returns before FD. I made that mistake first.
The lab
Host is macOS. Two containers. Compose project mysql-mysqlbinlog-raw-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:18620 and 127.0.0.1:18621. G1 used 18600/18601. G2 used 18610/18611.
dump is official mysql:26.7.0, user: "0:0", working_dir: /work, volume ./work:/work, sleep infinity. mysqlbinlog is not on PATH in that image. It lives at /usr/libexec/mysqlsh/mysqlbinlog. Version string: mysqlbinlog Ver 26.7.0 for Linux on aarch64 (MySQL Community Server (GPL)). --ssl-mode=DISABLED. --protocol=TCP. --raw. --read-from-remote-server. Log name mysql-bin.000001. Empty password. No --result-file.
The PoC wipes leftover files, runs control, checks work/mysql-bin.000001 exists and starts with \xfebin and work/oracle/ has no witness, then runs traversal and checks work/oracle/MYSQL-BINLOG-RAW-WITNESS exists, magic matches, mtime is this run, and the witness is not a cwd basename.
Control: 127-byte file, magic \xfe\x62\x69\x6e, FD payload starts 26.7.0. Exit 0. Oracle empty.
Traversal: 127-byte file at the absolute ident, same magic. Exit 0. cwd still has the control basename. cwd-witness-hits=[].
Last run:
control-basename-exists=True path=mysql-bin.000001
control-oracle-absent=True
control-head=b'\xfebin...' magic=True
traversal-exists=True path=work/oracle/MYSQL-BINLOG-RAW-WITNESS
traversal-mtime-ok=True magic=True
SUCCESS mysql-mysqlbinlog-raw-path control-basename=yes traversal-outside=yes dump=26.7.0 image=mysql:26.7.0 MYSQL-BINLOG-RAW-WITNESSReplay:
git clone https://github.com/abraxas/mysql-mysqlbinlog-raw-path
cd mysql-mysqlbinlog-raw-path/lab
./run.shBind it to loopback. The loopback client is lab/run.sh with a banner on it. The oracle is a file with BINLOG_MAGIC. Not a shell. Not RCE.
Wrong turns, in the order I made them
Fake Rotate ident = the path. Timestamp 0 plus a name that is not mysql-bin.000001 is return OK_CONTINUE. No FD. No file. The attack is a real Rotate after a matching fake one.
--result-file=/work/oracle/ as the demo. That prefixes the ident. snprintf("%s%s", prefix, ident) with ident MYSQL-BINLOG-RAW-WITNESS writes under the prefix on purpose. The leftover without --result-file is worse: ident is the whole path. I labbed that.
Using a real mysqld. Rotate idents are mysql-bin.NNNNNN. The parser will not put /work/oracle/... in a Rotate. The oracle is a stub.
Checksums left on. Without the SET @master_binlog_checksum='NONE' OK, mysqlbinlog may want a CRC on events. The stub events have none. check_master_version already sends that SET. Answer it.
mysqlbinlog on PATH. Official mysql:26.7.0 puts the client under mysqlsh: /usr/libexec/mysqlsh/mysqlbinlog. docker compose exec dump mysqlbinlog is "command not found." The PoC calls the full path. Version still 26.7.0.
A first FD before the real Rotate, then no second FD. Fake Rotate copies mysql-bin.000001 into log_file_name. An FD at that point writes the basename (control-like). The real Rotate then changes log_file_name. You need another FD to fopen the new path. Traversal stream in the lab is fake Rotate, real Rotate, one FD. The FD runs after log_file_name is already the absolute ident. No extra basename write on the traversal connection. Control basename on disk is from the control run.
LOAD DATA as this leftover. Begin_load_query uses hardcoded SQL_LOAD_MB under tmpdir. Closed. I did not file it.
Calling the file RCE. Contents are binlog bytes. You get create/truncate as the client UID. Cron or keys would be a later chain. The pack does not plant those. The witness is MYSQL-BINLOG-RAW-WITNESS with \xfebin.
Wine, or claiming the daemon is this bug. mysqld is not mysqlbinlog. --raw is not default mysqlbinlog without -R. Default daemon hunt is still NONE.
What an attacker can do
Run a MySQL-protocol listener. Complete the handshake. Answer SELECT VERSION() and the checksum SET. Accept COM_BINLOG_DUMP for mysql-bin.000001. Send a matching fake Rotate, a real Rotate whose ident is a path, and a FORMAT_DESCRIPTION event.
The operator who pointed mysqlbinlog --raw -R at that host creates or overwrites that path as the client UID. Bytes are a 127-byte 26.7.0-shaped binlog in the lab; a longer stream writes more events into the same file. You do not get FILE on the server. You get the client process's write access.
Same command without --raw prints SQL to stdout. Same command without -R reads a local file. This leftover is both flags, against a dump you do not own.
G1 without --tab is the overflow. G2 is --tab and a table name. G3 is --raw and a Rotate ident. Three leftovers, one protocol lie (the remote name is a filename), three sinks. I published them separately because they disclose separately.
The fix
basename new_log_ident before my_stpcpy / snprintf into log_file_name. --raw should write under cwd or the --result-file prefix. A Rotate ident with / is not a log name. Keep rejecting / in identifiers on the server. The client should not trust the dump more than the parser does.
Until then, do not point mysqlbinlog --raw -R at a MySQL you do not already trust with the client UID. TLS with VERIFY_IDENTITY does not fix a path copy. It does stop you from talking to a random listener that borrowed the name.
I am not going to print a Rotate ident you can paste at someone else's restore job. The missing basename is the useful part. If you own the client, run the proof of concept against loopback.
References
- Proof of concept: abraxas/mysql-mysqlbinlog-raw-path · mysql-mysqlbinlog-raw-path-Abraxas-Labs.py
- Lab:
lab/· Dockerfile · docker-compose.yml · run.sh · poc.py · stub.py - Siblings: mysqldump SHOW TABLES overflow · mysqldump --tab path
- @abraxas_null · github.com/abraxas · abraxaslabs.tech · abraxas.null@proton.me
- CWE-22
- Tree: mysql/mysql-server mysql-26.7.0 (
06a5c1c99c377fc41b2eba1ea244e8b220bdc3c8) ·mysqlbinlog.cc - Community advisories: dev.mysql.com/community/security/advisories