空密码的授权令: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 就是攻击者的完整武器库。
免责声明:本文仅用于授权环境下的安全研究与教学复现。