What this check reads
The check reads the global File_priv column of mysql.user with SELECT user,host FROM mysql.user WHERE File_priv='Y', excluding a fixed system list: root, mysql.session, mysql.sys, mysql.infoschema, mariadb.sys, mysql, debian-sys-maint and percona.telemetry. Anything still returned is an account that can read and write files on the server through SQL. The threshold is a single row: one non-system holder is a FAIL that names every user@host pair it found, and only an empty result passes.
When it applies
Runs only when MySQL/MariaDB is detected and reachable as root over the socket (MYOK=1); otherwise SKIP. It reads the global File_priv column of mysql.user, so it covers global grants only. A fixed system allow-list is excluded (root, mysql.session, mysql.sys, mysql.infoschema, mariadb.sys, mysql, debian-sys-maint, percona.telemetry); anything else holding FILE is a FAIL, including a backup or ETL account that legitimately needs it. Whether file operations are actually usable depends on secure_file_priv, which is the separate MySQL File Import/Export check - this one looks purely at who holds the privilege.
What each result means
| Result | When | What it means |
|---|---|---|
| FAIL | Any non-system account holds FILE | An account outside the system list can read and write server files through SQL, so an injection against it becomes filesystem access. |
| PASS | None | Only the built-in system accounts hold FILE, so no application login reaches the filesystem by this route. |
| SKIP | Not accessible | Nothing was verified. MySQL could not be queried, so the privilege table was never read and no account has been cleared. |
Why it matters
FILE lets a SQL session read any file the mysqld OS user can read (LOAD DATA INFILE, LOAD_FILE()) and write new files anywhere it can write (SELECT ... INTO OUTFILE). With SQL injection this becomes arbitrary file read and, on web hosts, webshell write. The manual calls it "a global privilege that can be dangerous". OWASP: "disable the FILE privilege for all users". CIS MySQL "Ensure 'FILE' is not granted to non-administrative users".
Why it fails, and when it is wrong
GRANT ALL ON *.*includes FILE. Application accounts created that way are the usual cause.- Backup users for
mysqldump --taborLOAD DATAETL jobs may legitimately need FILE; scope them withsecure_file_priv(see the MySQL File Import/Export check) and a dedicated account that the web app does not use. Super_privand other global columns are not part of this check.
How to fix it
List the accounts holding FILE
The FAIL message names every non-system holder. From the server, the privilege table gives you the same list.
sudo mysql -N -e "SELECT CONCAT(user,'@',host) FROM mysql.user WHERE File_priv='Y'"Revoke it
FILE is a global privilege, so it has to come off ON *.*; revoking it at database level reports success and changes nothing. Name the exact user@host pair, because a second row for the same user with a different host keeps the privilege.
REVOKE FILE ON *.* FROM 'app'@'%';Restrict the file surface as well
A backup account running mysqldump --tab, or an ETL job using LOAD DATA, may genuinely need FILE. Confine it with secure_file_priv pointing at a dedicated directory, make sure the web application uses a different account, and record this one as an accepted exception. That setting is the separate MySQL File Import/Export check, not this one.
Confirm the grant is gone
SHOW GRANTS FOR 'app'@'%';FILE should no longer appear anywhere in the returned grant list.
Verify the fix
sudo mysql -N -e "SELECT CONCAT(user,'@',host) FROM mysql.user WHERE File_priv='Y'"
# expected: only the system accounts (root, mysql.sys, debian-sys-maint, …)
sudo mysql -e "SHOW GRANTS FOR 'app'@'10.0.0.5'"
# expected: no FILE in the grant list
# Prove it from the application account itself:
mysql -u app -p -e "SELECT LOAD_FILE('/etc/passwd')"
# expected: NULL (or ERROR 1227 access denied)Debugging
MYOK=0; see the Authentication debugging notes.
REVOKE FILE ON *.* FROM … must name the exact user@host pair; a second row for the same user with a different host keeps the privilege. Re-run the first query above to see every remaining holder.
The exclusion list is fixed and cannot be extended. Confine the damage with secure_file_priv pointing at a dedicated directory, make sure the web application uses a different account, and record this one as an accepted exception.
You revoked at the database level (ON appdb.*) while FILE is a global privilege; it must be revoked ON *.*. Check with SHOW GRANTS.
An account can reach files via other routes: SUPER/dynamic privileges, or a permissive secure_file_priv. See the MySQL Admin Accounts and MySQL File Import/Export checks.
Sources
- MySQL 8.4: Privileges Provided by MySQL (FILE)
- MySQL 8.4: Security Guidelines (do not grant FILE)
- MySQL 8.4: REVOKE
- MariaDB KB: GRANT (FILE privilege)
- OWASP SQL Injection (file read/write impact)
- OWASP Database Security Cheat Sheet
How the script reads this
Next
Re-run the Least-Privilege Permissions audit after applying the fix and confirm this check moves to PASS. CtrlOps runs all 6 checks over your existing SSH connection and scores the result, so the change is visible without reading another config file.
All 6 Least-Privilege Permissions fixes