MySQL SET ROLE ALL EXCEPT leftover FILE
SET ROLE ALL EXCEPT (or empty DEFAULT) leaves CURRENT_ROLE() as NONE while static global bits stay in m_master_access. Labbed leftover FILE INTO OUTFILE as the mysqld UID on 26.7.0. Role-gated. No CVE yet.
- Name
- MySQL SET ROLE ALL EXCEPT leftover FILE
- Type
- 0-day analysis
- CVE
- n/a
- CVE Risk
- high
- Disclosure Status
- public
- Vendor
- Oracle MySQL
- Affected
- MySQL Community Server mysqld 26.7.0 (06a5c1c). Authenticated account granted a role that carries static global ACLs (FILE in the lab).
- Published
- 06 Oct 2026
- Updated
- 06 Oct 2026
- Tags
- 0-day, mysql, set-role, cwe-269, privilege-management
CURRENT_ROLE() said NONE. The file still appeared.
I am @abraxas_null. The proof of concept is on GitHub: abraxas/mysql-set-role-leftover (loopback client). The lab stack is lab/: Dockerfile, docker-compose.yml, run.sh, poc.py. Authorized lab only. It talks to loopback.
This is MySQL Community Server 26.7.0 (06a5c1c99c377fc41b2eba1ea244e8b220bdc3c8) mysqld. No CVE yet. CWE-269. 8.1 High (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N). Authenticated. Role-gated. The account is granted a role that carries a static global bit. The session activates that role, then deactivates it with SET ROLE ALL EXCEPT. CURRENT_ROLE() reports NONE. m_master_access still has FILE. INTO OUTFILE writes as the mysqld UID.
FILE with FILE is intended. FILE after you dropped the role is the leftover.
Fourth leftover, first daemon one
I had already published three client leftovers from the same pin: mysqldump SHOW TABLES overflow, mysqldump --tab path, mysqlbinlog --raw Rotate path. Those hit whoever points a client at a hostile server. Default mysqld unpublished Crit/High on 26.7.0 stayed empty through thirty-one hunts.
ACL was supposed to be empty too. GRANT still requires the bits plus GRANT OPTION. SYSTEM_USER is checked on user and role DDL. GRANT AS cannot impersonate a less-restricted account. SET ROLE NONE restores ACL_USER::access. Dynamic privileges fall back to the account's own mysql.global_grants row when the role map is gone. Oracle already closed a DB-level leftover as Bug#35386565: after empty roles, db_acl() is 0. The tests even assert that KILL fails after SET ROLE ALL EXCEPT system_user_role.
Static global bits did not get that treatment. FILE, PROCESS, SUPER, RELOAD live in m_master_access. check_access reads that integer. checkout_access_maps() is supposed to rebuild it from the active-role map, or restore the user, every statement. When the active-role list is empty it returns. The previous aggregated mask stays.
That is a sandbox bug. The documented way to drop a dangerous role in-session is SET ROLE ALL EXCEPT that_role, or SET ROLE DEFAULT with nothing in the default list. CURRENT_ROLE() agrees the role is gone. The next INTO OUTFILE does not.
Why this tree, why 26.7.0
I wanted a Community GA mysqld after the July 2026 CPU. Oracle Innovation is calendar versioning now. 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 that binary. 26.10.0 is Early Access. 26.7.1 / 8.4.12 / 9.7.3 are Docker-only August CPU. I pinned 26.7.0.
July CPU High Server rows are closed on this pin: X Plugin 60315/60316, Group Replication 60163, replication 61094, clone 60178/60183, connection-handling 46863. Router HTTP High rows 46860/46862/60314/60725 are closed in-tree; mysqld does not start Router. FILE INTO OUTFILE with FILE is intended. Empty root is install-time. activate_all_roles_on_login defaults false. Default accounts have no roles. mandatory_roles is empty.
Thirty-one hunts walked ACL, auth plugins, connection handling, X Plugin, 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, client tools, 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. Client tools produced G1-G3. ACL produced this.
I only wanted Critical or High. A leftover FILE write as the server UID is High. PROCESS leftover would have been a SHOW PROCESSLIST class. SUPER leftover would have been worse. The lab uses FILE because the oracle is a file.
The empty-role return
sql/auth/sql_security_ctx.cc checkout_access_maps is the per-statement subscribe:
void Security_context::checkout_access_maps(void) {
if (m_acl_map != nullptr) {
get_global_acl_cache()->return_acl_map(m_acl_map);
m_acl_map = nullptr;
}
if (m_active_roles.size() == 0) return;
m_acl_map =
get_global_acl_cache()->checkout_acl_map(this, uid, m_active_roles);
if (m_acl_map != nullptr) {
set_master_access(m_acl_map->global_acl(), m_acl_map->restrictions());
} else {
set_master_access(0);
}
}Return the old map. If there are no active roles, stop. Do not set_master_access(user->access). Do not set_master_access(0). Leave m_master_access as whatever the last aggregated map put there.
check_access is not subtle:
return (match_any ? (m_master_access & want_access)
: ((m_master_access & want_access) == want_access));FILE is a bit in that mask. The next statement calls checkout_access_maps() again (sql_parse.cc). Roles still empty. Early return again. Leftover persists for the rest of the session, until something actually writes m_master_access.
SET ROLE NONE is that something. activate_role_none in sql/auth/roles.cc:
bool Role_activation::activate_role_none() {
m_sctx->clear_active_roles();
m_sctx->clear_db_restrictions();
m_sctx->checkout_access_maps();
ACL_USER *user =
find_acl_user(m_sctx->priv_host().str, m_sctx->priv_user().str, true);
if (user) {
m_sctx->set_master_access(user->access,
acl_restrictions->find_restrictions(user));
}
// ...
}Checkout returns immediately (empty roles). Then NONE looks up the account and puts user->access back. If the account never had FILE on its own row, FILE is gone. That is the negative control.
SET ROLE ALL EXCEPT rfile is activate_role_all. It copies the granted-role list, skips anything in the EXCEPT list, activates the rest. If EXCEPT names every granted role, the active list stays empty. granted_roles.size() is still greater than zero (the skipped role is still in the vector). The function still calls checkout_access_maps() and db_acl(). db_acl() is 0 with no map. cache_current_db_access(0) wipes the session's DB cache. Global m_master_access is not restored.
Empty SET ROLE DEFAULT is the sibling: no default roles, checkout, acl_get for the current DB, same leftover mask.
SET ROLE ALL with remaining (non-excepted) roles rebuilds the map. That path is not this leftover.
Dynamic privileges are also not this leftover. has_global_grant with m_acl_map == nullptr reads the user's own mysql.global_grants row. SYSTEM_USER drops. The system_user_kill_connection test already covers that. Static bits do not live there.
The lab
Host is macOS. One official mysql:26.7.0. Compose project mysql-set-role-leftover. Loopback 127.0.0.1:18630. --activate-all-roles-on-login=OFF. --secure-file-priv=/var/lib/mysql-files. Root password labroot. Database lab.
Root seeds:
CREATE ROLE rfile;
GRANT FILE ON *.* TO rfile;
CREATE USER 'victim'@'%' IDENTIFIED BY 'victimpass';
GRANT SELECT ON lab.* TO 'victim'@'%';
GRANT rfile TO 'victim'@'%';
CREATE TABLE lab.t (id INT PRIMARY KEY, note VARCHAR(128));
INSERT INTO lab.t VALUES (1, 'MYSQL-SET-ROLE-LEFTOVER-WITNESS');
FLUSH PRIVILEGES;No default role. No GRANT FILE to victim. mysql.user.file_priv for victim is N. CURRENT_USER() is victim@%. USER() is victim@192.168.176.1 (compose gateway). % matches. Version string is 26.7.0. @@activate_all_roles_on_login is 0. @@secure_file_priv is /var/lib/mysql-files/.
Victim is one TCP session. SET ROLE is session-scoped. Split it across connections and you are measuring login, not leftover.
Login: CURRENT_ROLE() is NONE. SELECT note FROM lab.t INTO OUTFILE '/var/lib/mysql-files/before-role.txt' is 1227: you need FILE. Good. The account itself does not have FILE.
SET ROLE rfile. CURRENT_ROLE() is `rfile`@`%`. Same INTO OUTFILE to with-role.txt succeeds. FILE from the role map is in m_master_access. Intended.
SET ROLE ALL EXCEPT rfile. CURRENT_ROLE() is NONE. Role name matching worked on the first try (rfile equals rfile@%). I did not need the quoted `rfile`@`%` form.
Then the first leftover attempt: SELECT note FROM lab.t INTO OUTFILE '.../MYSQL-SET-ROLE-LEFTOVER-WITNESS'. 1142. SELECT command denied to user 'victim'@'192.168.176.1' for table 't'. Not 1227. Not FILE. Table SELECT.
That is Bug#35386565 doing its job. activate_role_all cached db_acl()==0. The session forgot victim's own GRANT SELECT ON lab.* until something reloads it. USE lab reloads via acl_get. FILE is a different question.
Constant SELECT does not need table SELECT:
SELECT 'MYSQL-SET-ROLE-LEFTOVER-WITNESS' INTO OUTFILE '/var/lib/mysql-files/leftover-const.txt';That succeeded while CURRENT_ROLE() was still NONE, before USE lab. FILE is in leftover m_master_access. After USE lab, the original table outfile succeeded too. Contents of /var/lib/mysql-files/MYSQL-SET-ROLE-LEFTOVER-WITNESS:
MYSQL-SET-ROLE-LEFTOVER-WITNESSSET ROLE NONE. CURRENT_ROLE() still NONE (it already was). SELECT note FROM lab.t INTO OUTFILE '.../after-none.txt' is 1227 again. NONE restored user->access. FILE is gone. The leftover is ALL EXCEPT / empty DEFAULT, not "any empty role list."
Last run:
victim-direct-file_priv='N'
step1 CURRENT_ROLE=NONE
step2 before-role outfile ok=False errno=1227
step4 CURRENT_ROLE=`rfile`@`%`
step5 with-role outfile ok=True
step7 CURRENT_ROLE after ALL EXCEPT rfile=NONE
step8 leftover outfile ok=False errno=1142
step8 leftover-const-outfile ok=True
step8 leftover outfile after USE lab ok=True
step10 after-none outfile ok=False errno=1227
leftover-file-rc=0 contents='MYSQL-SET-ROLE-LEFTOVER-WITNESS\n'
SUCCESS mysql-set-role-leftover before-file=denied with-role=yes leftover-role=NONE leftover-file=yes after-none=denied dump=26.7.0 image=mysql:26.7.0 MYSQL-SET-ROLE-LEFTOVER-WITNESSReplay:
git clone https://github.com/abraxas/mysql-set-role-leftover
cd mysql-set-role-leftover/lab
./run.shBind it to loopback. The loopback client is lab/run.sh with a banner on it. The oracle is a witness string in a file under secure_file_priv. Not a shell.
Wrong turns, in the order I made them
Table INTO OUTFILE as the leftover oracle. I wanted SELECT note FROM lab.t so the file would contain the witness from a real row. After ALL EXCEPT, that is 1142. db_acl() cache is 0. I almost marked FAIL. The 1142 is the closed DB leftover. FILE is the unpublished one. Constant SELECT 'witness' INTO OUTFILE is the FILE probe.
Reading 1142 as "FILE dropped." 1227 is FILE. 1142 is table SELECT. Different ACL class. select-before-use on lab.t was also 1142. Const outfile was not.
Expecting login to have FILE. activate_all_roles_on_login is OFF. Granted roles are inert until SET ROLE. If I had left it ON, login would already have FILE and ALL EXCEPT would still leftover, but the before-role control would be wrong. I pinned the variable OFF.
Role-name matching. I wrote a retry for SET ROLE ALL EXCEPT `rfile`@`%`. Unnecessary. SET ROLE ALL EXCEPT rfile already dropped `rfile`@`%`. Host matching for the user was % vs 192.168.176.1. That worked.
Claiming SYSTEM_USER leftover. Dynamic privs drop. The in-tree test is suite/auth_sec/t/system_user_kill_connection.test. I did not re-lab KILL. Static FILE is the High I could oracle with a file.
Claiming default-install. Default accounts have no roles. mandatory_roles is empty. Someone has to GRANT rfile TO victim first. The leftover does not mint FILE. It keeps FILE after the session asked to drop the role. That is still High against a sandbox that believed CURRENT_ROLE() .
Calling it RCE. FILE INTO OUTFILE as the mysqld UID is file write under secure_file_priv (and anywhere that UID can write if secure_file_priv is empty). The pack writes a witness. It does not plant a plugin. It does not write authorized_keys.
Using a stub mysqld. G1-G3 needed hostile protocol because a real server will not emit 64KiB table names or Rotate idents with slashes. G4 is real ACL. Official mysql:26.7.0 is the oracle.
Skipping SET ROLE rfile and going straight to ALL EXCEPT. If FILE was never aggregated into m_master_access, there is nothing to leftover. Login m_master_access is user->access without FILE. The sequence is activate, then ALL EXCEPT.
Treating Bug#35386565 as this bug. That closed DB-level ALL ON testdb2.*. roles_bugs.test does not assert FILE after ALL EXCEPT. The 1142 in the lab is that fix. The const outfile is this leftover.
What an attacker can do
Hold victim, already granted a role that carries FILE (or PROCESS, SUPER, ...). SET ROLE that role. SET ROLE ALL EXCEPT it. CURRENT_ROLE() is NONE. SELECT ... INTO OUTFILE still writes as the mysqld UID. LOAD_FILE still reads. An application that switches roles as a sandbox and then trusts CURRENT_ROLE() or "I just dropped FILE" is wrong.
SET ROLE DEFAULT with no defaults is the same class. SET ROLE NONE is the command that actually restores the account bits.
This is remote authenticated SQL. The leftover is in mysqld, on the connection you already have. UI is none. PR is low: you need the grant of the role, which you already used. You do not need a second admin session. You do not get a privilege the role never had.
G1-G3 were clients. G4 is the daemon. I published them separately because they disclose separately.
The fix
When m_active_roles is empty, checkout_access_maps() should set_master_access(user->access) the way activate_role_none already does. Empty ALL EXCEPT and empty DEFAULT should not keep the previous aggregated mask. Caching db_acl()==0 is fine. Leaving FILE in m_master_access is not.
Until then: SET ROLE NONE after you drop a static-global role. Do not sandbox with SET ROLE ALL EXCEPT. Do not trust CURRENT_ROLE() as the FILE bit. secure_file_priv still bounds INTO OUTFILE; empty secure_file_priv makes leftover FILE worse.
I am not going to print a GRANT you can paste at someone else's server. The missing restore in checkout_access_maps is the useful part. If you own the daemon, run the proof of concept against loopback.
References
- Proof of concept: abraxas/mysql-set-role-leftover · mysql-set-role-leftover-Abraxas-Labs.py
- Lab:
lab/· Dockerfile · docker-compose.yml · run.sh · poc.py - Siblings: mysqldump SHOW TABLES overflow · mysqldump --tab path · mysqlbinlog --raw Rotate path
- @abraxas_null · github.com/abraxas · abraxaslabs.tech · abraxas.null@proton.me
- CWE-269
- Tree: mysql/mysql-server mysql-26.7.0 (
06a5c1c99c377fc41b2eba1ea244e8b220bdc3c8) ·sql_security_ctx.cc·roles.cc - Community advisories: dev.mysql.com/community/security/advisories