空密码的授权令:MariaDB 纯 SQL 攻击链,从 lowpriv 到容器内命令执行
字数 6051
更新时间 2026-10-25 15:07:47

空密码的授权令:MariaDB 纯 SQL 攻击链,从 lowpriv 到容器内命令执行

一句话结论:攻击者手里只有一个只有 USAGE 权限的低权限数据库账号,唯一外界通道是 3306 端口的 TCP 连接,然后只用 SQL 语句就把整条链走完:先把自己变成数据库 root,再读出服务进程的内存布局,接着堆利用 + JOP 链,在容器内以 mysql 用户(uid 999)执行任意命令。全链不需要读写 /proc//mem、不需要 root 密码,所有地址都是从目标自己身上现取现用。

排版说明:为规避排版转义问题,文中命令里的字符串统一用单引号书写,正文引用一律使用「」。

一、前言

2026 年 5 月底发布的 MariaDB 13.0.1-rc,带着一条完整的远程代码执行攻击链。这条链由两个独立缺陷拼成:

  • 一个是权限提升:GRANT PROXY 语句在显式指定空认证时,权限检查被整个跳过,任意用户可以把 root 账号的密码覆盖成空——一条语句,无需任何权限(F-09);
  • 一个是内存破坏:SYS_REFCURSOR 存储过程游标数组的 use-after-free,配合堆喷射和 JOP 链,拿到任意命令执行(F-05)。

修复状态更值得玩味。提权那个缺陷,官方在开发分支修了,但补丁没进任何发布版本——从 13.0.1 一路追溯到 10.6.27,全线裸奔。UAF 那个,官方 JIRA 有同类的游标 use-after-free 修复记录(MDEV-38561,Critical,修复于 13.0.2 / 11.8.9 / 11.4.13),但其根因是 sp_lex_instr 的 mem_root 重复释放,与本文 F-05 的游标数组扩容悬垂指针并非同一处。国内漏洞库已给出编号 QVD-2026-48306 的风险通告并验证了可复现。

二、两个缺陷各是什么

2.1 F-09:GRANT PROXY 的语义漏洞

GRANT PROXY 是 MariaDB/MySQL 的代理授权语法,允许一个账号「代理」另一个账号的身份登录。既然是「代替别人登录」,这个语法的信任模型天然敏感——正常设计里,执行 GRANT PROXY 必须通过授权者权限检查。

关键在 IDENTIFIED VIA 子句,它指定代理登录时的认证方式,语法允许显式写一个空认证:IDENTIFIED VIA ''。正常流程下服务器要检查执行者是否有资格发出这条授权,问题就出在「显式空认证」的处理上:服务器把「我明确写了空认证」和「我根本没写认证」混为一谈。于是这条语句畅通无阻:执行者不需要任何 GRANT 权限,服务器跳过了权限检查,按字面意思把 root 账号的认证信息替换成空密码。瞬间,最高权限账号成了密码为空的敲口账号。

2.2 F-05:SYS_REFCURSOR 的 use-after-free

存储过程支持游标,MariaDB 里有一类叫 SYS_REFCURSOR 可动态打开,执行期间由 sp_cursor_array 这个动态数组统一管理。问题出在数组的内部指针上:某个函数把数组内部元素的地址直接返回给调用方,而数组本身是动态的——元素增加时底层存储会被重新分配,旧地址立即失效,但调用方不知道。

攻击者恰好能控制「元素增加」的时机:游标 open 时执行的是攻击者写的 SQL,SQL 里再打开更多游标,数组就长大了,缓存的那个指针指向的已经是释放掉的旧存储。

三、F-09 源码走读:一次被跳过的授权检查

入口是任意低权限账号都能执行的 SQL。语法解析时 IDENTIFIED VIA '' 会被解析成一个认证对象;修复前的语法规则无条件预分配这个对象,不管用户写没写认证子句,解析器都先造一个空的 USER_AUTH 结构。

真正的判断在 LEX_USER::has_auth()。修复前它的实现是:plugin、密码、文本三个字段全空则整体返回 false。字面看合理(没有认证信息),但语义错了:IDENTIFIED VIA '' 是「我显式指定了空认证」,不是「我没指定认证」。has_auth() 返回 false 意味着调用方认为这条 GRANT PROXY 没带认证子句,而带与不带的检查路径完全不同。修复提交信息说得很白:

empty password and empty plugin name don't mean authentication was not specified, they mean empty authentication was specified

has_auth() 返回 false 后,调用链跳过了对授权者的权限检查,但写库动作没有跳过:replace_user_table() 依然执行,把 root 的认证信息替换成空密码。概括:判断「有没有认证」的逻辑,被「认证内容是否为空」的逻辑覆盖了。

修复 commit 改两个文件,逻辑非常干净。structs.h 把 has_auth() 改成只判空指针:

-    return auth && (auth->plugin.length || auth->auth_str.length || auth->pwtext.length);
+    return auth;

sql_yacc.yy 删除无条件预分配,让「没写认证子句」真正表现为 auth 等于 NULL:

-        
$$
->auth= new (thd->mem_root) USER_AUTH();

从此,空指针 = 没指定认证;非空对象(哪怕字段全空)= 显式指定了空认证,后者必须走完整的授权检查。配套回归测试:新建一个无密码用户,让他尝试 GRANT PROXY(指定 foo 或空字符串认证),预期是权限拒绝错误。

四、F-05 源码走读:悬垂的内部指针

入口是 SYS_REFCURSOR 的 OPEN。执行 OPEN FOR SELECT 时,服务器从游标数组里取对应元素,取元素的核心函数 get_cursor_by_ref(sql/sp_cursor.cc):

sp_cursor_array_element *sp_cursor_array::get_cursor_by_ref(THD *thd, Field *ref_field, bool for_open)
{
  Type_ref_null ref = ref_field->val_ref(thd);
  if (ref < (ulonglong) elements())
    return &at((size_t) ref.value());   // 返回的是数组内部存储的元素地址
  ...

注意 return &at(...),它返回的是指向动态数组内部的原始指针。数组类型是 Dynamic_array,增长路径(sql/sql_array.h):

void append(const Elem &el) { insert_dynamic(&array, &el); }

insert_dynamic() 在容量不足时重新分配底层 buffer,旧 buffer 被释放、元素搬进新地址。危险就在:get_cursor_by_ref() 返回的内部指针被调用方缓存,而游标 open 过程中执行的是攻击者可控的 SQL,SQL 里再打开更多游标→数组 append→存储重分配→旧指针悬垂。利用链里这一步被精确编排:外层游标 open 的 SQL 里打开 15 个额外游标,配合三轮回收分配/释放的诱饵对象,把数组顶到增长点。

悬垂指针指向的内存已被释放,但分配器还没把这块 1792 字节(16 个游标元素 乘 112 字节)分给别人。攻击者用 128 个 1784 字节的用户变量 blob 做堆喷射,把这块空间精确回收。每个 blob 的偏移 0x20 处(恰好是 sp_cursor 结构里 result 成员的位置)埋着攻击者控制的 vtable 指针。后续代码对悬垂指针做虚拟派发 result->prepare(),实际取到的 vtable 是攻击者布的局:

; 虚拟派发机制示意(非实际反汇编)
result->prepare()
  -> mov rax, [result]      ; rax = 攻击者的 vtable 指针
  -> call [rax + 0x20]      ; D2 gadget

五、攻击链全程拆解(六步)

第一步:一条 GRANT PROXY 换一个 root

低权限账号执行两行,没有权限门槛。执行完 root 密码为空,用 root + 空密码重连已经是最高权限:

GRANT PROXY ON CURRENT_USER() TO root@% IDENTIFIED VIA '';
GRANT PROXY ON CURRENT_USER() TO root@localhost IDENTIFIED VIA '';

第二步:从服务进程嘴里问出内存布局

提权后有了 FILE 权限。原版镜像里 secure_file_priv 未设置,LOAD DATA INFILE 可以读服务器进程能读的任何文件,包括它自己的内存映射:

LOAD DATA INFILE '/proc/self/maps' INTO TABLE appdb.maps_pre;

/proc/self/maps 列出 mariadbd 进程的全部内存段:PIE 基址、libc 基址一行行躺着,ASLR 当场失效。

第三步:要一块地址已知的大缓冲区

JOP 链需要一块「内容可控 + 地址已知」的内存。SQL 侧的办法是用户变量:

SET @fake = REPEAT(CHAR(0xDE), 134217728);

128 MiB 的 REPEAT 结果。glibc 对这么大的分配走 mmap,给它一块专有区域(数据起始偏移 +0x30)。这块区域的地址通过 maps 前后对比算出来,同样从 SQL 侧完成。

第四步:把 JOP 布局写进缓冲区

地址已知、内容可控,把利用布局写进去:SET @fake = CONCAT(REPEAT(...), UNHEX('布局'), REPEAT(...))。UNHEX 解决了 SQL 里塞二进制的问题;布局里的自引用指针(vtable 指向命令字符串)用第三步算出的地址烘焙进去。glibc 在释放旧块再分配时复用同一个 mmap 槽位,地址稳定;每一步都重新验证,槽位万一挪了就把地址重新烘焙重试。

第五步:CALL uaf5() 点火

核心触发语句就一个:

CALL uaf5();

过程体里打开一个 SYS_REFCURSOR,其 open 的 SQL 执行 grow5(),里面 15 个游标加诱饵分配把数组顶到增长点,随后释放的 1792 字节块被预先排好的 128 个 blob 回收,回收 blob 的偏移 0x20 处是受控 vtable。

第六步:两个 gadget 直达 system()

JOP 链只有两个 gadget,全部来自原版二进制,没有 ROP、没有栈迁移:

Gadget 偏移 指令 用途
D2 PIE+0x80da77 call *0x100(%rax) 栈对齐修正
D1 PIE+0xe3075b mov rdi,[rax+0xa8]; call [rax+0xa0] 取命令字符串指针,调 system()

执行以 uid 999(mysql 用户)身份完成。命令跑完,服务进程崩溃——对利用者来说正好,崩溃即证明。重启容器读标记文件,收工。六个步骤,五个在 SQL 里完成,唯一的外部操作是重启容器看结果。

六、本机复现

环境是 Docker + 原版 13.0.1-rc 镜像,没做任何修改。项目地址 github.com/dinosn/mariadb-13-rce-lab,在 Kali 中部署:

docker-compose up -d

执行 POC(默认 3306,详见 yml):

python3 exploit_pure_sql.py --host 127.0.0.1 --port 3306 --user lowpriv --password lowpriv --command 'id > /tmp/pwned' --marker /tmp/pwned --container mariadb-rce-lab

脚本会自动完成:读 maps 拿基址、分配 128 MiB 缓冲、写 JOP 布局、触发 UAF、执行命令。命令以 uid 999 在容器内执行,标记文件写入成功。

tip:注意不是去自己 Kali 的 tmp 目录下看,得去容器里面看。

七、为什么这条纯 SQL 链值得琢磨

攻击者只有 SQL 一个接口,他是怎么把「任意命令执行」从数据库里抠出来的:

  • 第一道坎是二进制载荷。SQL 字符串是文本但指针是 8 字节二进制,解法是 UNHEX,把十六进制文本转回原始字节,靠一个函数打通文本世界与字节世界。
  • 第二道坎是自引用。JOP 布局里的 vtable 要指向自己的命令字符串,但写布局时地址还不知道。解法是分两步走:先分配一块纯填充的 128 MiB 标记缓冲,从 maps diff 出新区域算出地址;再重新分配完整布局——glibc 复用同一个 mmap 槽位,地址不变。地址已知的问题被分配器的确定性行为解决了。
  • 第三道坎是精确回收。UAF 释放的块是 1792 字节,喷射的 blob 是 1784 字节,差 8 字节对齐到 glibc 的 chunk 尺寸,不多不少正好吃掉同一个 chunk。堆喷射不是乱喷,是测过尺寸的精准覆盖。
  • 第四道坎是控制流。不用 ROP(需要栈迁移和大量 gadget),选 JOP:两个 gadget,一个修正栈对齐,一个把命令字符串装进 rdi 再调 system。
  • 第五道坎是稳定性。整条链跑在四个全新 ASLR 基址上,任何一步地址漂移都会前功尽弃。披露方测试结果是 4 次运行 4 次成功——每轮都从 maps 重新读基址、重新验证 mmap 槽位,地址一旦挪动就把自引用重新烘焙重试。

这套思路的价值在于:它演示了数据库攻击从「注入进 SQL」到「SQL 即利用语言」的转变。传统 SQL 注入还在拼字符串,这里的攻击者已经在 SQL 里完成整个内存利用工程。

八、影响与修复状态

  • F-09(提权)影响所有发布版本——修复提交在开发分支,13.0.1 到 10.6.27 的每个发布版都不含补丁。
  • F-05(UAF):官方已有游标 use-after-free 修复记录(MDEV-38561);本文分析的 get_cursor_by_ref 悬垂指针路径与 MDEV-38561 是否同一处,需对照官方 commit 确认。
  • 攻击起点低:任意低权限账号 + 3306 端口可达;但完整链还依赖 F-09 提权成功(未含补丁的版本)以及 secure_file_priv 未设置(才能读 /proc/self/maps)。

有一个细节:F-09 的补丁躺在开发分支上,发布分支没合入。这种「修了但没发布」在数据库这种长周期产品里很常见,但补丁存在的消息本身就是一张攻击面地图。

九、检测与加固

9.1 SQL 侧检测(最有效)

链的起点是 GRANT PROXY 授权,这是可以用查询直接抓的,GRANT PROXY 在正常业务里几乎不出现,出现即可疑:

SELECT * FROM mysql.tables_priv WHERE Table_priv LIKE '%PROXY%';
SELECT * FROM mysql.proxies_priv;
SELECT User, Host, plugin, authentication_string FROM mysql.user WHERE User IN ('root', 'admin');
SELECT * FROM mysql.general_log WHERE argument LIKE '%GRANT PROXY%' OR argument LIKE '%IDENTIFIED VIA%';

9.2 配置加固(理论建议,未逐一实测)

# 1. 阻断 /proc/self/maps 读取(打破 ASLR 泄露):secure_file_priv = /var/lib/mysql-files
# 2. 限制大用户变量(阻断 128 MiB mmap 缓冲):max_allowed_packet = 16M
# 3. 监控 mysql.user 表变更(捕捉提权动作):用审计插件或外部 SIEM 监控 GRANT 语句

注意 secure_file_priv 会同时影响正常业务的文件导入导出,max_allowed_packet 压得太狠可能伤到合法大查询,改之前先评估业务侧。

9.3 版本策略

  • 优先等官方发布含 F-09 修复的稳定版本并跟进升级;
  • 对无法升级的环境:数据库账号最小化(低权限账号不允许 GRANT)、数据库端口不暴露公网、密码定期轮换;
  • 关注官方安全公告中 F-05(SYS_REFCURSOR)的修复进展。

十、反思

  • 关于空值语义:F-09 的根子是「空字符串和未指定被当成一回事」。NULL 和空串、默认密码和显式空密码、未设置和设置为空,这类坑在数据库生态里反复出现。审计这类代码时,「区分没说话和说了句空话」应该是个固定检查项。
  • 关于修复流程:补丁躺在开发分支三个月,发布分支一个没有,这件事比漏洞本身更值得警惕。对防御者来说,「修了」和「可修复」之间的真空期,是唯一真正危险的窗口。
  • 关于 SQL 的攻击面:这条链的利用全程用 SQL 完成,没有一个环节需要数据库之外的接口。数据库审计如果只看「注入了什么」,看不到「SQL 本身在干什么」,那么下一场攻防里,SQL 就是攻击者的完整武器库。

免责声明:本文仅用于授权环境下的安全研究与教学复现。

相似文章
相似文章
小程序二维码
 全屏