Research/mysql-install-component-scheme
0-dayNo CVEHighPublic

MySQL INSTALL COMPONENT qualified scheme, plugin_dir skip

file:// INSTALL COMPONENT is forced under plugin_dir. URN scheme file.mysql_minimal_chassis still acquires the unfiltered loader and dlopens an absolute path. Labbed 26.7.0 with a stock component. PR:H.

Name
MySQL INSTALL COMPONENT qualified scheme, plugin_dir skip
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). INSTALL COMPONENT (INSERT on mysql.component). Qualified URN file.mysql_minimal_chassis.
Published
07 Oct 2026
Updated
07 Oct 2026
Tags
0-day, mysql, install-component, cwe-427, plugin-dir

file:// was filtered. The other scheme was not.

I am @abraxas_null. The proof of concept is on GitHub: abraxas/mysql-install-component-scheme (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-427. 7.2 High (CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H). Authenticated. PR:H. INSTALL COMPONENT is INSERT on mysql.component. Docs say the library lives in plugin_dir. file:// is wrapped by mysql_server_path_filter and forced there. The unfiltered libminchassis file loader stays registered as dynamic_loader_scheme_file.mysql_minimal_chassis. Name that implementation in the URN scheme and dlopen walks an absolute path as the mysqld UID.

INSTALL PLUGIN and CREATE FUNCTION SONAME still reject /. That hole is closed. This leftover punches it for components.

The lab copies stock component_query_attributes.so. It does not ship a custom component.

Sixth leftover, last of the G# list

Same pin as the five I already published. Default 26.7.0 mysqld unpublished Crit/High is still NONE. UDF / plugin / component load was supposed to be intended-admin-inside-plugin_dir. Unpriv INSTALL COMPONENT is INSERT-denied (Bug#24528148). check_valid_path is any FN_DIRSEP in a SONAME. Default file://../ and file:/// fail ER_COMPONENTS_CANT_LOAD in component.test. The tests never try the qualified scheme.

I wanted Critical or High. dlopen of an attacker path as the server UID is High. PR is high because you already needed INSTALL COMPONENT. SUPER still cannot INSTALL PLUGIN ... SONAME '/tmp/x.so'. The leftover is that INSTALL COMPONENT can, if you spell the scheme.

Oracle Innovation is calendar versioning. 26.7.0 is the full Community GA from 28 July 2026. GitHub tags mysql-26.7.0 and mysql-cluster-26.7.0 share 06a5c1c99c377fc41b2eba1ea244e8b220bdc3c8. Official image mysql:26.7.0 is that binary. plugin_dir in that image is /usr/lib64/mysql/plugin.

Thirty-one hunts on this pin. This one is sql/sql_component.cc, components/libminchassis/dynamic_loader*.cc, sql/server_component/dynamic_loader_path_filter.cc, sql/mysqld.cc around set_default. Client tools were G1-G3. ACL was G4. Duality DML was G5. Components are G6.

Two implementations, one service name

Startup sets the default file scheme to the filter:

C
set_default("dynamic_loader_scheme_file.mysql_server_path_filter");

The filter strips ://, check_valid_path, length caps, then opt_plugin_dir + '/' + file (dynamic_loader_path_filter.cc:106-136). Comment at the function: only approved libraries from the plug-in directory. That is the security boundary the docs describe.

The unfiltered implementation stays registered (minimal_chassis.cc) because the server keeps a handle for the wrapper and never unregisters it. Registry stores both the service name and service.implementation. get_scheme_service_from_urn takes the bytes before :// as the scheme, concatenates dynamic_loader_scheme_ + scheme, and acquires by full implementation name:

C
my_string const scheme(urn.begin(), urn.begin() + scheme_end, ...);
my_service<SERVICE_TYPE(dynamic_loader_scheme)> service(
    (my_string("dynamic_loader_scheme_") + scheme).c_str(),
    &imp_mysql_minimal_chassis_registry);

URN file.mysql_minimal_chassis:///tmp/MYSQL-INSTALL-COMPONENT-SCHEME-WITNESS is scheme file.mysql_minimal_chassis. That acquires dynamic_loader_scheme_file.mysql_minimal_chassis. The unfiltered loader does strstr(urn, "://")+3, appends .so, dlopen. No plugin_dir prefix. No check_valid_path. Persistence writes the same URN into mysql.component. Restart reloads it.

bad_scheme:// is ER_COMPONENTS_NO_SCHEME_SERVICE. This scheme is real.

plugin_dir is READ_ONLY NON_PERSIST. Default is CMAKE PLUGINDIR then my_load_path against mysql_home. You do not SET GLOBAL it as a leftover. INSTALL PLUGIN, CREATE FUNCTION SONAME, and mysql.func bootstrap all check_valid_path then strxnmov(opt_plugin_dir, "/", name). Absolute /tmp/x.so and ../x.so fail ER_UDF_NO_PATHS. A planted mysql.plugin row with dl='../evil.so' is rejected at restart, not loaded. Unpriv INSTALL COMPONENT is INSERT-denied on mysql.component (component.test:199-214, Bug#24528148). Those are closed. The qualified scheme is the one component.test does not mention.

Persistence is persistent_dynamic_loader.cc. A successful INSTALL writes the URN you typed. init on startup reloads every row. If the leftover loads once, it loads after reboot unless someone UNINSTALLs.

The FILE chain is optional. Root in the lab cps from plugin_dir to /tmp. An account that also has FILE can SELECT ... INTO DUMPFILE under secure_file_priv. Empty secure_file_priv makes that worse. The leftover does not require FILE. It requires a .so the server UID can open, plus INSTALL COMPONENT, plus the scheme name.

The lab

Host is macOS. One official mysql:26.7.0. Compose project mysql-install-component-scheme. Loopback 127.0.0.1:18650. Root password labroot. Version 26.7.0. @@plugin_dir is /usr/lib64/mysql/plugin. mysql.component starts empty.

List the plugin dir. Copy stock unused component_query_attributes.so to /tmp/MYSQL-INSTALL-COMPONENT-SCHEME-WITNESS.so inside the container. 199952 bytes. No custom .so. Internal name was not already loaded, so no collision.

Negative: INSTALL COMPONENT 'file:///tmp/MYSQL-INSTALL-COMPONENT-SCHEME-WITNESS' is 3529 Cannot load component from specified URN. Filter ran. Optional: INSTALL PLUGIN lab_scheme SONAME '/tmp/MYSQL-INSTALL-COMPONENT-SCHEME-WITNESS.so' is 1124 No paths allowed for shared library. SONAME path reject still works.

Positive: INSTALL COMPONENT 'file.mysql_minimal_chassis:///tmp/MYSQL-INSTALL-COMPONENT-SCHEME-WITNESS' succeeds. Three slashes. The fourth slash was a guess I did not need. Loader appends .so; the URN stem omits it. SELECT from mysql.component is (id, ..., 'file.mysql_minimal_chassis:///tmp/MYSQL-INSTALL-COMPONENT-SCHEME-WITNESS'). Second INSTALL of the same URN is 3529 already-loaded. UNINSTALL works. Reinstall after uninstall works. The row comes back.

Last run:

Plain text
plugin_dir='/usr/lib64/mysql/plugin'
filtered-file-slash errno=3529
install-plugin-path errno=1124
qualified-3slash ok urn='file.mysql_minimal_chassis:///tmp/MYSQL-INSTALL-COMPONENT-SCHEME-WITNESS'
mysql.component-final=[(..., 'file.mysql_minimal_chassis:///tmp/MYSQL-INSTALL-COMPONENT-SCHEME-WITNESS')]
SUCCESS mysql-install-component-scheme file-slash-denied=yes qualified-load=yes component-urn=yes dump=26.7.0 image=mysql:26.7.0 MYSQL-INSTALL-COMPONENT-SCHEME-WITNESS

Replay:

Plain text
git clone https://github.com/abraxas/mysql-install-component-scheme
cd mysql-install-component-scheme/lab
./run.sh

Bind it to loopback. The loopback client is lab/run.sh with a banner on it. The oracle is a stock component loaded from /tmp plus a mysql.component URN. Not a shell. Not a weaponized module.

Wrong turns, in the order I made them

Four slashes. Hunt text said file.mysql_minimal_chassis:///<abs>. strstr("://")+3 on scheme:///tmp/foo is /tmp/foo. Three slashes after the scheme name is :// plus /tmp/.... That loaded first.

Leaving .so on the URN. Loader always appends .so. Stem is the path without suffix.

Compiling a custom component. Unnecessary. Official image already has unused component libraries. Copy one. component_query_attributes.so was not in mysql.component. No name collision.

Treating filtered file:// as the leftover. That path is closed. component.test covers file://../ and file:///. The leftover is the implementation-qualified scheme.

Claiming unpriv load. INSTALL COMPONENT still needs INSERT on mysql.component. Unpriv is 1142-class. PR:H. The hole is "outside plugin_dir", not "without the install priv."

INSTALL PLUGIN as this leftover. 1124. Closed. Do not file it.

Calling it default-install RCE. Default accounts cannot INSTALL COMPONENT. Someone with that priv plus a .so they can place (FILE DUMPFILE under secure_file_priv, or a local write) is the chain. The leftover is the URN scheme, not FILE.

Loading a component already in plugin_dir via the qualified scheme. That would still dlopen the outside copy if the path is /tmp/.... Using the basename file://component_query_attributes is intended and filtered. The demo is the outside path.

UNINSTALL as the leftover. UNINSTALL working means the loader accepted the URN as a real component. It is a control, not the bug.

Wine, Windows .dll. The Unix loader appends .so. Windows would append .dll. The lab is the official Linux image. Same scheme name.

What an attacker can do

Hold INSTALL COMPONENT. Place a component shared object outside plugin_dir. INSTALL COMPONENT 'file.mysql_minimal_chassis:///abs/name'. mysqld dlopens /abs/name.so. The URN is persisted. Restart loads it again.

Remote SQL. UI none. PR high. Combined with FILE, secure_file_priv still bounds DUMPFILE; empty secure_file_priv plus this scheme is a component load from a path you can write. The pack copies a stock library. It does not demonstrate a malicious init.

G1-G3 were clients. G4 was leftover FILE after SET ROLE. G5 was DEFINER SQLi on a duality view. G6 is the install-path leftover. I published them separately because they disclose separately.

The fix

Do not acquire dynamic_loader_scheme_file.mysql_minimal_chassis from SQL URNs. Map only file to the path-filter implementation. Reject a scheme that names an implementation. Keep check_valid_path on every file loader. component.test should include the qualified scheme and expect ER_COMPONENTS_CANT_LOAD.

Until then: accounts with INSERT on mysql.component can load from outside plugin_dir if they can write a .so the server UID can dlopen. Treat that grant as "load any component path," not "basename in plugin_dir."

I am not going to print a component init you can paste at someone else's datadir. The scheme name is the useful part. If you own the daemon, run the proof of concept against loopback.

References

MySQL INSTALL COMPONENT qualified scheme, plugin_dir skip · Abraxas Labs