
Эксплойт Android LPE для CVE-2026-43499, нацеленный на OPPO PMG110 (ядро 6.6). Использует futex PI UAF для получения root-прав и установки su-демона через LD_PRELOAD.
Локальное повышение привилегий через CVE-2026-43499 (use-after-free в futex PI rt_mutex_waiter), портированное на 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): uid=0 примерно за 35 секунд при «чистом» запуске без переопределений окружения, и su затем отвечает из обычного непривилегированного adb shell:
$ 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 и через .incbin встраивается в .rodata библиотеки, поэтому он находится внутри preload.so и записывается обратно во время выполнения. Путь warhol-root, без изменений.ksud, нет KernelSUsu устанавливается в трёх местах, потому что одно из них — то, до которого вы действительно доберётесь:
Демон слушает /data/local/tmp/temp_su.sock и пишет лог в /data/local/tmp/su_daemon.log. Root не сохраняется после перезагрузки — повторно запускайте команду с LD_PRELOAD после каждой загрузки.
Для установки KernelSU используйте вместо этого маршрут /data/local/tmp/a/e в ghostlock-oneplus.
Всё, кроме ядра эксплойта, принадлежит warhol-root — взято, а не изобретено заново:
targets/<device>/, размещаемые в source/src/ во время сборки, так что переключение DEVICE никогда не оставит позади заголовки предыдущего устройстваsource/Makefile (NDK, если он есть, иначе хост-clang против sysroot NDK) и двухэтапное правило встраивания, которое создаёт build/embed/su_daemon_aarch64_pie перед линковкой .sosu_daemon.c и su_blob.S побайтно идентичны warhol-root, а su_install.c — это его установщик из 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 — это один и тот же код в обоих деревьях.
Это единственное структурное различие, и оно обусловлено тем, что два дерева получают root по-разному.
warhol-root рутит сам процесс эксплойта и потому вызывает install_embedded_su() напрямую из run_direct_root(). Здесь Write 2 подменяет указатель cred у форкнутого дочернего процесса, а родитель остаётся непривилегированным вызывающим, поэтому дочерний процесс в child_main() — единственный контекст, который может выполнить установку, — именно там он и запускается.
Оба дерева несут одну и ту же слабую заглушку install_embedded_su() в util.c, возвращающую ENOSYS; именно сильное определение включает этот маршрут. Это стоит знать, потому что сборка, в которой по какой-то причине теряется 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, затем обычные места установки NDK для Linux и macOS, а если ничего из этого не нашлось — хост-clang, нацеленный на sysroot NDK. Устанавливайте ANDROID_NDK_HOME только для переопределения поиска. make info выводит, что было выбрано.
Сборка двухэтапная — вот что стоит знать:
su_daemon.c → build/embed/su_daemon_aarch64_pie — отдельный aarch64 PIEsu_blob.S через .incbin встраивает этот бинарник в .rodata, и всё это линкуется в единый preload.soПоэтому make clean и пересборка — единственный способ изменить встроенный su: достаточно отредактировать один su_daemon.c, зависимость объявлена, но блоб — артефакт сборки и не отслеживается.
targets/<device>/{target.h,device_offsets.h} заново размещаются в source/src/ при каждой сборке, так что устаревший заголовок от другого устройства не может быть молча подхвачен.
out/*.so не отслеживается (та же договорённость, что и в warhol-root) — клонируйте и запускайте make.
.so собирается с флагом -fvisibility=hidden и экспортирует ноль символов. Библиотека LD_PRELOAD выигрывает поиск символов для всего процесса, поэтому всё, что она экспортирует, может затенять одноимённый символ в хост-бинарнике или в libc. Этот флаг управляет только генерацией кода C, поэтому su_blob.S помечает свои два символа .hidden вручную — без этих строк границы блоба были бы единственным, что библиотека по-прежнему экспортировала.
При проверенном запуске не понадобилась ни одна из них.
[*] 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) внутри него конкретно означает, что слинковалась именно слабая заглушка.
mm_struct leak failed с последующим prepare_kernel_page retry N/24 — не сбой. Это прогресс цикла, и успешный запуск тоже показывает его. Ничего не является сбоем, пока не исчерпаны 24 попытки и не появится prepare_kernel_page timeout. Аналогично, probing cfi ... expected=9 с child uid = 2000 — это один недобранный раунд из десяти.
Не судите о запуске по усечённому логу — эта ошибка стоила здесь целого раунда неверной диагностики.
Полностью неудачный запуск — тоже норма. Гонка 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 для root-оболочки |
/data/local/tmp/su | доступен из обычного adb shell без игр с PATH |
/apex/com.android.virt/bin/su в mount namespace 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 — см. «Сборка») |
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 | root получен, su отвечает |
EXPLOIT OK, SU INSTALL FAILED | Write 1 и Write 2 сработали; сломалась только установка |
EXPLOIT FAILED | записи не сработали |
ABORTED | запуск завершился, не успев сообщить результат, — читайте последнюю строку [!] |
| Путь | Содержимое |
|---|
source/src/preload.c | конструктор: запускает эксплойт, сообщает результат, останавливается |
source/src/main.c | сам эксплойт (Write 1 / Write 2) |
source/src/su_daemon.c | бинарник su — собирается отдельно как aarch64 PIE, не линкуется в .so |
source/src/su_blob.S | через .incbin встраивает этот PIE в .rodata файла .so |
source/src/su_install.c | записывает блоб обратно, запускает демон, проверяет его |
source/src/target.h | место размещения (игнорируется git) |
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 |