
GhostLock (CVE-2026-43499) для OPPO Find X5 Pro (PFEM10) — реверс-инжиниринг watchdog и детектора heap-spray от OPlus
English · 中文
Порт GhostLock (CVE-2026-43499) для OPPO Find X5 Pro на ColorOS 16. Достигает дочернего процесса с uid=0 и загруженного kernelsu.ko; корневой процесс перехватывается.
CVE-2026-43499 — use-after-free в futex PI. remove_waiter() очищает current->pi_blocked_on, когда current является requeuer, на пути отката -EDEADLK в rt_mutex_start_proxy_lock().
remove_waiter @ 0xffffffc0081ed254 — форма до исправления.
О «корневой процесс выживает»: запуски в evidence/kill.log
достигают uid=0 и загружают kernelsu.ko, и в запуске, который действительно опрашивал это,
процесс менеджера KernelSU выжил 120 с, при этом kernelsu оставался Live в
/proc/modules. В более позднем запуске та же цепочка оставила службы Android framework
недоступными (Can't find service: package/power/input/phone/wifi),
пока модуль всё ещё был Live. Ни одной строки ядра [ROOTCHECK-*] и ни одной
полезной нагрузки $$sys_call_number@@ никогда не было захвачено, поэтому причина
состояния более позднего запуска не атрибутирована. См. evidence/notes.md
§2.3, §2.4 и §7.
task_struct
thread_info
| Поле | Смещение |
|---|---|
cred
LT perf leak target task_struct → file W7 stage 1 task+0x778 = V (real_cred → private sprayed page; V observed) W7 stage 2 task+0x780 = V (cred) ★ V12_W7_VALUE=V — THE SAME VALUE, not a new page W7 stage 3 V+8 = 0 (LOCAL repair of the page that was installed, ZERO shape) LT child fexecve(memfd of loader) — no execve of a /data path loader ksud late-load → kernelsu ... Live
**Этап 2 должен явно получить значение этапа 1.** Этапы 1 и 2 — это два
независимых процесса, каждый со своим spray, поэтому «записать страницу учётных данных в оба
слота» — это ловушка: при наивном чтении это даёт `(pageA, pageB)`, и поскольку
`commit_creds` сравнивает **указатели**, эта пара является расходящейся, даже когда обе записи
происходят. Это не гипотетическая ситуация — именно это и произошло в запусках 3 и 9:```
run 9 0x778 shot write value = 0xffffff88679bade0
0x780 shot write value = 0xffffff8785d6ade0 <- a different page
run 3 0x778 shot write value = 0xffffff8787b5ade0
0x780 shot write value = 0xffffff881bad2de0 <- a different page
run_bootA.sh поэтому запускает stage 2 с V12_W7_VALUE=<значение, наблюдённое на stage 1> и отказывается запускать его вообще, если это значение не удаётся восстановить. HOLD должен пережить stage 2, иначе страница stage 1 освобождается и перераспределяется, и «то же самое значение» превращается в висячий указатель. См. правило одного и того же значения.
За одну загрузку восстанавливается одна страница. Stage 3 обнуляет V+8. При двух разных страницах обнуление обеих стёрло бы отпечаток gid/suid (см. ниже) и заставило бы расхождение выглядеть как согласие, поэтому раннер восстанавливает только ту страницу, которая была фактически установлена, и останавливается, если два значения расходятся.
Страница cred строится в payload.c: все восемь полей id равны нулю, все пять наборов capability заполнены, а user / user_ns / group_info указывают на root_user / init_user_ns / init_groups. Stage 3 существует потому, что побочный эффект записи всегда затирает cred+8 (gid/suid) того cred, который он устанавливает.
init_cred — явная дихотомияДва раздела здесь раньше противоречили друг другу («никогда глобальный init_cred» против «CONTROL=1 воспроизводит cell 2», а cell 2 и есть init_cred). Оба утверждения верны для разных ролей:
init_cred заставляет побочный эффект повредить init_cred+8 глобально — init_cred разделяется всеми потоками ядра, и Uid: 0 0 4294967176 0 — это в точности то повреждение. Код отказывается идти по этому пути, если только V12_ALLOW_INIT_CRED=1 не задан намеренно.0xffffff802a7e0be0) в оба слота, так что real_cred == cred по построению — именно поэтому она дожила до execve. CONTROL=1 воспроизводит это. Это контроль, а не конфигурация, на которой следует строить.утечка perf: PERF_TYPE_SOFTWARE / PERF_COUNT_SW_CPU_CLOCK, PERF_SAMPLE_REGS_INTR, exclude_user=1.
Принимать [0xffffff8400000000, 0xffffff90000000), голосов ≥ 15%.
UAF управляется через rb_erase_cached Case 1-left. Это даёт две записи, а не одну:```
*(write_target) = write_value // the store you aim
*(write_value + 0x08) = write_target // unavoidable side effect
`write_value` должен быть выровнен по 8 байтам с обнулённым битом 0 — это либо `0`, либо
допустимый адрес ядра. **Именно поэтому `g_boot_state` нельзя установить с помощью этого
примитива**: байт, который должен стать `1`, имеет младший бит, принудительно обнулённый
требованием выравнивания, а `write_value` — это та же величина, что и
адрес, по которому происходит побочный эффект.
### Побочный эффект записывает в то, на что указывает `write_value`
`write_value` — это одновременно *сохраняемое значение* и *адрес, по которому побочный эффект
производит запись* (по смещению `+8`). Направьте его на глобальный объект ядра — и вы повредите этот
объект.
**W7 раньше делал именно это** — нацеливая `write_value` на псевдоним `init_cred` —
и это видно при обратном чтении. Из `out/t5_w7_778.txt`:```
shape shift=0 wps=5: in[0]=0xffffff802a7e0be0 (write_value) in[2]=0xffffff8800cdd178 (write_target)
W7[W7] write_target= 0xffffff8800cdd178
Uid: 0 0 4294967176 0
write_value был алиасом init_cred, а write_target — child_task+0x778.
init_cred+8 — это gid/suid, поэтому побочный эффект записал туда
0xffffff8800cdd178: init_cred.gid = 0x00cdd178 и init_cred.suid = 0xffffff88 = 4294967176 — в точности 4-е поле awk из строки Uid: выше. Обнуление
init_cred+8 исправило это (out/t5_repair.txt: Uid: 0 0 4294967176 0 →
), и это всё, чем когда-либо был «W7 stage 3».
Теперь этот путь запрещён в коде. V12_W7_INIT_CRED=1 прерывается с
объяснением, если только не установлен также V12_ALLOW_INIT_CRED=1, а пути
W2/W6/LTC больше не откатываются к init_cred, когда приватная страница cred
отсутствует — они вместо этого прерываются. Значение по умолчанию, и
единственный разумный путь — это распылённая страница cred.
Сам побочный эффект невозможно избежать: write_value должен быть указателем
на cred, поэтому cred+8 всегда затирается целью записи. Выбор есть только в
отношении его местоположения — и теперь исправление представляет собой
локальное обнуление cred_page+8 (stage 3), а не запись в глобальный
объект.
Мусорный вывод
groups=— это отдельный симптом, не этот. Он был замечен в запуске, гдеgidиegidчитались чисто, поэтому он не может происходить от побочного эффектаinit_cred+8; он указывает на собственное полеgroup_infoподдельного cred. См.evidence/notes.md§10.6.
BUG_ONПримитив записывает ровно по одному адресу за проход. task+0x778
(real_cred) и task+0x780 (cred) — это два отдельных адреса, поэтому любая
приземлившаяся запись только в 0x778 или только в 0x780 оставляет задачу с
cred != real_cred — состояние расхождения.
На этом образе такое состояние — жёсткая паника, а не предупреждение.
commit_creds начинается с BUG_ON(task->cred != task->real_cred):```
commit_creds @0xffffffc008186784
0x1867a4 ldr x19, [x20, #0x778] ; old = task->real_cred
0x1867a8 ldr x8, [x20, #0x780] ; task->cred
0x1867ac cmp x8, x19
0x1867b0 b.ne #0xffffffc008186b68
0x186b68 brk #0x800 ; == BUG()
и ядро собрано с **`CONFIG_PANIC_ON_OOPS=y`** (`CONFIG_PANIC_ON_OOPS_VALUE=1`).
`__put_cred @0xffffffc008185530` несёт то же семейство утверждений
(`usage != 0` → BUG; `cred == current->cred` / `current->real_cred` → BUG).
Таким образом, расхождение *латентно* — оно ничего не делает, пока жертва просто крутится, —
до тех пор, пока на этой задаче не произойдёт **любой** `commit_creds`: `setresuid` / `setresgid` /
`setuid` / `setgid` / `capset`, или **`execve` через `install_exec_creds`**.
> **⛔ Отозвано (2026-09-18 поздно): это НЕ механизм перезагрузки.**
>
> Более ранняя редакция этого раздела называла расхождение «ведущим кандидатом
> на роль механизма перезагрузок» и утверждала, что оно «объясняет разделение по форме».
> Это не так, и причина теперь измерена, а не аргументирована:
>
> * `commit_creds` берёт свою задачу из **`current`** — `0x1867a0 mrs x20, sp_el0`.
> Его сигнатура — `commit_creds(struct cred *new)`; аргумента задачи нет.
> Поэтому расхождение имеет значение только если задача, **держащая** его, сама
> вызывает `commit_creds`.
> * Все перезагружающиеся прогоны были `V12_NO_EXEC=1` (указано дословно в
> `run3_0445.log:18`, `run9_0606.log:20`, `run10_0616.log:24`), поэтому жертва
> никогда не выполняла `execve` и вообще не достигала `commit_creds`.
> * В прогоне 10 не было никакого poke вообще (`grep -c poke` = 0).
> * `exit_creds` обнуляет **оба** указателя перед `put_cred`
> (`0x185cb8 str xzr,[x19,#0x778]`; `0x185d24 str xzr,[x19,#0x780]`), поэтому
> `_exit(0)` жертвы **стирает** расхождение, а не спотыкается о него.
>
> ⇒ В тех прогонах расхождение было **инертным**. `BUG_ON` реален, но это
> мина, которая не сработала. «Форма A никогда не перезагружается» снова становится
> корреляцией. Что мина на самом деле ограничивает — это **отмывание**, потому что
> `setresgid`/`setresuid` сами вызывают `commit_creds`.
>
> **Величина, которая на самом деле разделяет цепочки, — это равенство указателей, а это
> означает, что оба выстрела должны записать ОДНО значение** — см. следующий подраздел.
### ★★★ Оба выстрела должны записать *одно и то же* значение — не просто оба попасть
`BUG_ON` сравнивает **указатели**. Две страницы, обе несущие `uid 0`, — всё ещё два
разных объекта. Захваты делают различие конкретным:
| цепочка | выстрел 0x778 | выстрел 0x780 | указатели |
|---|---|---|---|
| старая (`t5loop.sh MODE=CRED`) | `in[0]=0xffffff802a7e0be0` | `in[0]=0xffffff802a7e0be0` | **равны** → ksud загружен, менеджер жив 120 с |
| новая (`run_bootA.sh`) | `0xffffff88679bade0` (прогон 9) | `0xffffff8785d6ade0` | **различаются** → расхождение даже при обоих попаданиях |
`tools/t5loop.sh` применяет **один** `$ENVV` к **каждому** смещению, поэтому `MODE=CRED`
сделал оба выстрела идентичными *по построению*. `run_bootA.sh` выполнял шаг 5 и
шаг 6 каждый с пустым `$extra`, поэтому каждый распылял свою **собственную** страницу.
⇒ Требование — **«оба выстрела записывают одно и то же значение»**. `run_bootA.sh` теперь
обеспечивает это (`SAME_VALUE=1`, значение по умолчанию): шаг 6 дословно переиспользует
наблюдённый `write_value` шага 5 и **отказывается стрелять вообще**, если не может восстановить это
значение — потому что выстрел построил бы расходящуюся пару.
⚠ `HOLD` должен пережить второй выстрел. Если PIN-потомок первого выстрела умирает первым,
страница освобождается и перераспределяется, и «одно и то же значение» становится висячим указателем.
Значение по умолчанию `HOLD=20` **слишком короткое**; используйте `HOLD=600`. Теперь это *значение по умолчанию*
всякий раз, когда `SAME_VALUE=1` — прежние безусловные 20 с означали, что сама конфигурация по умолчанию
была ловушкой — и явный короткий `HOLD` с
`SAME_VALUE=1` теперь громко предупреждает вместо тихого создания висячего указателя.
⚠ `CONTROL=1` раньше менял **только шаг 5**, поэтому он производил
`(init_cred, свежая страница)` — расходящуюся пару — в то время как этот файл утверждал, что он
воспроизводит ячейку 2. Исправлено: теперь он устанавливает оба выстрела в `init_cred`. (Цена ячейки 2
остаётся: побочный эффект глобально повреждает `init_cred+8`, что и есть
`Uid: 0 0 4294967176 0`.)
**Последствия для всего, что хочет отмыть учётные данные**
(`setresgid` + `setresuid`, чтобы обменять распылённую страницу на настоящий `struct cred`):
- Механизм реален и проверен — `commit_creds` записывает `x21` в **оба**
`task+0x778` и `task+0x780` (`0x186998` / `0x1869a0`), поэтому один вызов навсегда
чинит расщепление; `prepare_creds @0xffffffc008186070` — это
`kmem_cache_alloc(cred_jar)` + `memcpy(new, task->cred, 0xA8)` +
`security_prepare_creds(...)`, и 147/149 оба в списке исключений стража.
- **Но его предусловие противоположно «пропустить выстрел 0x778».** Само отмывание
вызывает `commit_creds`, поэтому его можно выполнять только когда **оба** указателя
уже держат одно и то же значение.
- `V12_LAUNDER=1` ограничен **двумя** вещами, и первая — не наблюдение:
1. **`V12_W7_SAME_VALUE=1`** — факт *происхождения*, что обоим выстрелам было дано
одно и то же значение. Без примитива чтения идентичность указателей ненаблюдаема, поэтому
это нельзя заменить лучшей проверкой в пространстве пользователя; это должно быть объявлено.
2. `consistent=1` — представление `0x780` (`getuid()`) согласуется с представлением `0x778`
(`/proc/self/status` `Uid:`). **Необходимо, но само по себе недостаточно**:
две разные страницы, обе несущие `uid 0`, читаются одинаково, пока указатели различаются
— а это ровно тот случай, который раннер раньше фабриковал. При условии (1) это
становится достаточным: согласуется + одно и то же значение ⇒ оба попали на одну страницу.
Провал любой из проверок ⇒ отказ, и таблица из четырёх случаев идёт в доказательства.
Строка отчёта LT печатает оба представления (`uid=` / `real_uid=` / `consistent=`) плюс
`same_value_declared=`, чтобы состояние читалось, а не выводилось.
### ★ Инструмент 1 — побочный эффект это ШТАМП, нацеленный на цель
`*(write_value + 8) = write_target`, а `cred+8` / `cred+0xc` — это `gid` / `suid`,
поэтому одно 8-байтовое сохранение ложится поперёк обоих:```
cred.gid = low32(write_target)
cred.suid = hi32(write_target)
Это измерение, а не модель. В out/t5_w7_778.txt указано
write_target = 0xffffff8800cdd178 и Uid: 0 0 4294967176 0, где
4294967176 = 0xffffff88 = hi32(write_target); в notes.md §11 зафиксирована другая
половина, init_cred.gid = 0x00cdd178 = low32(write_target).
Два применения:
task+0x778. /proc/<pid>/status читает
real_cred = task+0x778 — ровно тот cred, который только что был установлен, — так что метка
напрямую читается из userspace. Читайте её до этапа 3: восстановление
обнуляет cred+8 и стирает её (t5_repair.txt из notes.md §11 читает
4294967176 до успешного восстановления и 0 после).0, поэтому две
различные страницы обе сообщают «согласовано». Но low32(T+0x778) и
low32(T+0x780) различаются ровно на 8, так что при двух страницах getgid() (из
) и (из ) расходятся — а
сравнивает gid так же, как uid.⇒ V12_W7_SAME_VALUE — это второй шлюз, а не единственный. Он всё ещё важен:
метка различает только если сработали оба побочных эффекта, так что правило одинаковых значений
закрывает эту остаточную дыру. И заметьте, что такое шлюз есть — детектор, а не
предотвратитель. Он может только отказать; он оставляет задачу расходящейся до конца
загрузки. Правило одинаковых значений — это то, что делает пару корректной, что и было у
старой цепочки и что необходимо, чтобы вообще достичь execve.
probe_state НЕ является критерием приземленияОн ошибался трижды в этом проекте: W1 приземлился на глобальную переменную и
сообщил R; D из запуска 12 был нацелен на глобальную переменную, а не на cred; и R из запуска
11 был записан в таблицу так, будто это было приземление
(run11_w778r1_miss.txt и w7_w7781.txt из запуска 7 построчно
изоморфны — оба probe_state = R, probe_done = 0). Используйте оракул для каждой цели:
run_bootA.sh теперь использует метку для этапа 1 — что и делает повторные попытки
ROUNDS>1 на task+0x778 осмысленными, поскольку неудачный раунд читается, а не
выводится логически, — и он не запустит этап 2, если этап 1 не приземлился.
⛔ Говорите «поле awk», никогда «3-е поле».
uid_lineпечатает и метку (Uid: 0 0 4294967176 0), так что$1в awk — это"Uid:", а четыре значения id —$2..$5:$2=uid$3=euid$4=suid$5=fsuid. Метка находится поcred+8, т. е.gid(low32) иsuid(hi32) — так что этоGid:$2иUid:. Называть это «третьим полем» (что считает и как это формулирует §11) провоцирует код читать , который равен = на поддельном cred и никогда не может равняться . Эта ошибка на единицу присутствовала здесь: возвращал «нет метки» для выстрела, который приземлился, так что этап 2 никогда не срабатывал, и шлюз отмывания отказывал вечно — , потому что «нет метки» — это также нормальный результат настоящего промаха.
Критерий, который никогда не проверяется против известного положительного образца, — это не
критерий, а догадка — и этот класс отказов (эта ошибка на единицу,
probe_state, dmesg -w, пустой klog.host, пустое обратное чтение) всегда
проявляется как «ничего не произошло», что также является законным экспериментальным исходом.
Так что теперь проверка защищена дважды:
stamp_selftest() выполняется в предполётной проверке и делает exit 9 при отказе, прогоняя
те же функции извлечения, которые использует шлюз, против измеренных значений из
out/t5_w7_778.txt (write_target = 0xffffff8800cdd178 → Uid $4 =
4294967176, Gid $2 = 13488504) плюс отрицательные и нечитаемые образцы.
Самотест, который заново реализует проверку, ничего не доказывает, поэтому извлечение полей
вынесено в uid_suid_field / gid_gid_field.tools/test_stamp_criterion.sh — то же самое в виде
автономного регрессионного теста, извлекающего реальные функции из
.stamp_ok() возвращает три состояния, потому что «не удаётся прочитать» — это не «нет метки»
(именно это смешение заставило запуск 13 выглядеть как «без изменений»): 0 = присутствует,
1 = читается и метки нет, 2 = НЕЧИТАЕМО. И когда он возвращает 1, а
probe_state = D, раннер печатает ⛔ ORACLE INCONSISTENT — «идите проверять
критерий» — вместо сообщения «не приземлилось», которое отправляет оператора в
совершенно другое место (свежая загрузка или охота за частотой попаданий).
Полный вывод: evidence/2026-09-18-divergence-is-latent.md
и evidence/2026-09-18-cred-launder-verification.md
(§2.3 последнего отозван на месте). Самостоятельная проверка перекрытия формы записи
закрыта офлайн: слова формы живут в сетке fd_set на стеке ядра, пока
побочный эффект приземляется внутри распылённой страницы, так что они не могут перекрываться ни в одной
из форм.
Три независимых репортёра. Ни один не является запасным для другого, и только путь 1 может убить вызывающую задачу.
Путь 2 срабатывает на execve, а не на смене учётных данных, и это отдельный код
путь от пути 1. Он сообщает через kevent_send_to_user, так что что произойдёт
дальше — решение демона userspace, а не ядра.
Проверка идёт по пути исполняемого образа, так что недостаточно
загрузить через memfd полезную нагрузку загрузчика: если сам загрузчик исполняется из
/data/local/tmp, этот первый execve уже сообщает. d_path() для memfd —
это /memfd:…, так что сам загрузчик должен исполняться через memfd —
V12_EXEC_MEMFD теперь включён по умолчанию именно по этой причине. Старое поведение
видно в RUN 4:```
LT child exec /data/local/tmp/glx12 (4 args) <- execve of a /data path while uid=0
LT child memfd loaded 5014624 bytes (fd=5) <- memfd only protected the second image
Greppable-маркеры для пути 2:```
[ROOTCHECK-EXEC-INFO]:common %s result %s with "execve_report" / "execve_block"
%d,path@@%s kevent payload fragment
Поскольку путь 2 и путь 3 сообщают только через kevent, «отсутствие [ROOTCHECK-*] в журнале ядра» не исключает срабатывания ни одного из них. Для такого вывода нужен получатель в пространстве пользователя, который мы не обнаружили.
oplus_security_guard.koКэш sys_enter:```asm
ldrsw x8, [x1, #0x118] ; regs->syscallno
mrs x9, sp_el0 ; current
ldr x10, [x9, #0x780] ; cred
str x8, [x9, #0xdf8]
ldr w8, [x10, #4] → str x8, [x9, #0xe00] ; uid
ldr w8, [x10, #0x14] → str x8, [x9, #0xe08] ; euid
ldr w8, [x10, #8] → str x8, [x9, #0xe10] ; gid
ldr w8, [x10, #0x18] → str x8, [x9, #0xe18] ; egid
Проверка `sys_exit`:```asm
ldr x0, [x8, #0xe00] ; cached uid
cbz x0, #0x48c ; cached uid == 0 → return
adrp x9, #0 ; ldrb w9, [x9] ; g_boot_state
tbnz w9, #0, #0x48c ; is_unlocked → return
ldr x9, [x8, #0x780] ; cred
ldr w3, [x8, #0xdf8] ; cached syscallno
cmp x0, w10 ; b.hi #0x468 ; uid descending → kill path
; euid / gid / egid, same shape
ldr x9, [x8, #8] ; addr_limit
cmp x9, #0x8000000001
b.lo #0x48c ; addr_limit != KERNEL_DS → return
sub w9, w3, #0x8f ; syscallno - 143
cmp w9, #0x47
b.hi #0x4a0 ; outside 143..214 → kill
ldrsw x12, [x10, x9, lsl #2] ; jmp table @ .rodata+0
br x11
0x48c: ret
0x4a0: bl oplus_root_check_succ ; printk + kevent_send_to_user
bl oplus_root_killed ; printk + do_exit(SIGKILL)
Замечание о потоке управления (важно для порядка эксплуатации). Четыре
сравнения по нисходящему ребру ветвятся напрямую к 0x468, к диспетчеру
по номеру системного вызова — они не проходят через шлюз addr_limit.
0x454–0x464 достигаются только тогда, когда ни один id не спустился.
Таким образом, диспетчер входит, когда либо какой-то id спустился,
либо addr_limit == KERNEL_DS; он не управляется addr_limit.
Последствия:
sys_enter всё ещё кэшировал старый uid.
Если задача уже uid=0 при входе в системный вызов (0x400 cbz), хук
возвращается и остаётся слепым с этого момента.delivery/外部建议评审_2026-09-18.md.g_boot_state — 1 байт .data..ro_after_init, устанавливается при инициализации
модуля из verified_bootstate через strstr. is_unlocked() = LDRB + RET.
Записи в VA модуля вызывают сбой (CONFIG_STRICT_MODULE_RWX=y) — используйте
псевдоним physmap 0xffffff80….
Полезная нагрузка отчёта: $$sys_call_number@@%d, $$set_id_flag@@%d, $$addr_limit@@%lx, $$enforce@@%d.
.rodata+0, индексы 143–214Остальные 60 записей → отчёт + kill.
Не блокируйте поток на sendmsg (211), пока меняются его учётные данные. Его
нет в таблице, поэтому поток будет зарегистрирован и убит. Единственные системные
вызовы, в которых безопасно блокироваться для этой цели, — двенадцать
вышеперечисленных: setregid, setgid, setreuid, setuid, setresuid,
setresgid, connect, getsockname, setsockopt, shutdown, readahead, brk.
Исправление (2026-09-18). Более ранняя редакция этой таблицы помечала каждую запись на единицу меньше реального номера системного вызова arm64 (
146называлсяsetresuid; на самом деле этоsetuid—setresuidэто147). Номера всегда были верны; неверны были только имена. Теперь имена разрешаются изsys_call_table@0xffffffc00a13d8c0в образе ядра этого устройства. В частностиsendmsg(211),munmap(215),getsockopt(209) иgetpeername(205) не освобождены — блокировка потока в любом из них, пока меняются его учётные данные, это kill, а не pass. Перегенерируйте с помощьюtools/gen_exempt_table.py.
oplus_secure_harden.ko`entry_handler_setxattr` → `oplus_heapspray_check(7)`, `entry_handler_cpuinfo` → `(6)`, оба безусловные.
`oplus_heapspray_check` — счётчики `heapspary_ip4` / `ip6` / `xttr` / `cpuinfo`, дельта `ktime_get_real_ts64`, 7 мест отчёта (`snprintf` + `printk` + `kevent_send_to_user`), с проверкой `verified_bootstate`.
### Обход
| Примитив | |
|---|---|
| `setsockopt` optname 46 `MCAST_JOIN_SOURCE_GROUP` | не фильтруется |
| `setsockopt` level `SOL_IPV6` (41) | не фильтруется, если фильтр читает `level` |
| `setxattr` | всегда учитывается |
| `/proc/cpuinfo` | всегда учитывается |
| `socket()` / `socketpair()` | не перехватывается |
| `sendmsg`, `pipe`, `memfd`, `add_key`, `io_uring`, mmap | не перехватывается |
## Конфигурация```
CONFIG_CFI_CLANG=y
CONFIG_PTR_AUTH=y
CONFIG_SHADOW_CALL_STACK=y
CONFIG_STRICT_MODULE_RWX=y
CONFIG_STATIC_USERMODEHELPER=y
CONFIG_STATIC_USERMODEHELPER_PATH=""
CONFIG_SET_FS=y
CONFIG_RANDOMIZE_BASE=y
CONFIG_RANDOMIZE_MODULE_REGION_FULL=n
CONFIG_UNMAP_KERNEL_AT_EL0=y
CONFIG_ARM64_VA_BITS=39
CONFIG_ARM64_SW_TTBR0_PAN=y
CONFIG_SLAB_FREELIST_RANDOM=y
CONFIG_SLAB_FREELIST_HARDENED=y
CONFIG_INIT_ON_ALLOC_DEFAULT_ON=y
CONFIG_RANDOM_KMALLOC_CACHES=n
CONFIG_USER_NS=n
CONFIG_NF_TABLES=n
CONFIG_SYSVIPC=n
CONFIG_ANDROID_BINDER_IPC=y
CONFIG_KASAN=y
perf_event_paranoid = -1
NDK r28c. -O1 / API 26 / -D__ARM=1 фиксированы — они сохраняют геометрию стекового фрейма reclaim (delta=0 калибровка). Изменение любого из них требует повторной калибровки на устройстве.```bash
export ANDROID_NDK_HOME=/path/to/android-ndk-r28c
make # → exploit_guard
./build.sh # same, with NDK auto-detection
Вручную:```bash
"$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android26-clang" \
-D__ARM=1 -O1 -Wall -Wextra -pthread -Isrc/core -Isrc/devices/pfem10 \
-o exploit_guard src/core/exploit.c
CI-сборки при каждом push (.github/workflows/build.yml, Ubuntu + NDK r28c, артефакт exploit_guard).
adb push exploit_guard /data/local/tmp/e adb shell chmod 755 /data/local/tmp/e adb shell /data/local/tmp/e
## Файлы```
src/core/ exploit.c payload.c payload.h fdset_map.h
src/lib/ KernelSnitch — kernelsnitch.h futex_hash.h timeutils.h utils.h
src/devices/pfem10/ pfem10_target.h
model/ model.c — host-side rtmutex chain-walk model
tools/ kdis.py kdis_ko.py kdis_ko_reloc.py gen_guard_disasm.py
gen_exempt_table.py mod_layout.py sct_dump.py
find_task_off.py slide_resolve.py
test_stamp_criterion.sh regression test for the 0x778
landing criterion (run it after
touching uid_line/gid_line)
artifacts/ guard_post_handler.s kill chain, relocations resolved
guard_relocs.txt raw .text relocation dump
guard_disasm.txt guard + heap-spray detector
guard_exempt_table.txt 72-slot jump table, real names
evidence/ kill.log notes.md — device captures and their limits
Makefile build.sh exploit build (-O1, API 26, NDK r28c)
run.sh device-side run orchestration (retry across reboots)
.github/workflows/ build.yml — cloud build + artifact
evidence/kill.log — дословные стенограммы adb shell четырёх
запусков от root: полная временная шкала, момент, когда uid целевой задачи становится 0,
загрузка kernelsu.ko и состояние после этого. Сначала прочитайте блок заголовка: в нём
перечислено, чего файл не содержит и почему.
evidence/notes.md — сторона ядра. Адреса модулей и
какие каналы /proc работают в каком состоянии SELinux; полный вывод g_boot_state,
включая ключ strstr; исправленная таблица исключений; рецепт захвата, который позволил бы
получить недостающую половину со стороны ядра; и список того, что всё ещё
открыто.
evidence/2026-09-18-bootA/ — первый
запуск текущего дизайна на устройстве, 13 загрузок. Перезагрузки упорядоченные
(bootreason=reboot), и строка паники ни разу не была захвачена — но читайте это
как выборку из одного, а не из тринадцати. Из четырёх запусков, которые перезагрузились, два сохранили
пустой klog.host, один сохранил лог следующей загрузки, и только одно окно может
правдоподобно охватывать собственную перезагрузку. Аналогично, probe_state одного запуска и обратное чтение жертвы
оба пусты (устройство уже исчезло), так что это не несёт
информации о том, попала ли его запись — пустое поле не означает «без изменений».
Каталог также документирует методологическую ошибку, о которой стоит знать:
dmesg -w на этом устройстве ничего не делает (toybox выводит один раз и завершается), поэтому
лог ядра более раннего запуска содержал только историю до захвата — «нет [ROOTCHECK-*]» не
было доказательством чего-либо. evidence/notes.md §6 содержит исправленный
рецепт опроса и потоковой передачи на хост.
evidence/2026-09-18-cred-launder-verification.md
— проверка предложения по отмыванию учётных данных против собственного
дизассемблирования этого образа (не общего исходного кода 5.10): двойное сохранение commit_creds, выделение
prepare_creds и измеренный sizeof(struct cred) = 0xA8, описанная выше
опасность расхождения BUG_ON(cred != real_cred), самопроверка закрытого перекрытия формы записи
и аудит покрытия доказательствами 13 загрузок.
§2.3 отозван на месте — расхождение латентно, а не является механизмом
перезагрузки.
evidence/2026-09-18-divergence-is-latent.md
— проверка второго раунда. commit_creds берёт свою задачу из current
(0x1867a0 mrs x20, sp_el0), exit_creds обнуляет оба указателя перед put_cred,
и необработанные захваты показывают, что старая цепочка записала одно идентичное значение
(0xffffff802a7e0be0) в оба слота, тогда как новая цепочка записала две разные
страницы. Таким образом, критерий — равенство указателей — «оба выстрела записывают одно и то же значение»,
а не «оба выстрела попадают».
postreboot_forensics.sh — криминалистика после перезагрузки, которая
не зависит от поллера. Критерий — единственное условие:
CONFIG_PSTORE_CONSOLE=y заставляет panic() записать хвост консоли в ramoops при
kmsg_dump(KMSG_DUMP_PANIC) — до любого сброса — так что то, перезагрузится ли затем машина
или зависнет, не имеет значения. Извлекает /sys/fs/pstore/, ищет kernel BUG /
__put_cred / cred.c и печатает строку причины загрузки (записи истории
несли суффиксы reboot,shell / bootloader / reboot,edl, так что причина
различает действующее лицо там, где эпоха этого не делает).
⚠ Не читайте «чистый
bootreason=reboot» как «нет паники». На QCOM срабатывание сторожевого таймера SoC сбрасывается через блок PMIC PON, так чтоpanic → panic_timeout=-1 → зависание → сторожевой таймер → сброс PMIC → чистая причина загрузки— это самосогласованная цепочка, которая неотличима от аппаратного сброса на имеющихся у нас доказательствах. Собственныйtotal_17_dump_0_pmic_17этого репозитория приписывает все 17 аномальных перезагрузокpmic, что в точности соответствует нормальной форме сторожевого таймера, а не является доказательством «не ядро».bootreasonздесь ничего не сужает; ramoops — единственный критерий.
Два предварительных условия, иначе вердикт скрипта недействителен (铁律 8 — вывод об отсутствии сигнала требует, чтобы канал был сначала доказанно достижим):
/sys/fs/pstore/* доступен только root, поэтому при Enforcing
и adb pull, и cat терпят неудачу — а «не удаётся прочитать» даёт тот же вывод,
что и «прочитал, и там было пусто». Двухсостоянийный скрипт печатает «pstore ПУСТ ⇒
паника опровергнута» из канала, который он никогда не открывал. Поэтому скрипт выдаёт
CHANNEL UNREACHABLE (ls не удался, или все известные записи не удалось прочитать,
а не то, что они не существуют) и сообщает getenforce вместе с этим.adb reboot, за которым следует немедленное извлечение. Если
заведомо исправная перезагрузка не даёт ничего читаемого, канал не доказан и каждое
последующее «пустой pstore» не является доказательством. Порядок важен: устройство перемещает
и удаляет запись вскоре после загрузки, поэтому последовательность такова:
перезагрузка → получить Permissive (W1) → немедленно запустить скрипт.run_bootA.sh — оркестрация для той одной загрузки, в том порядке,
который важен (0x778 → 0x780 с тем же значением → локальный ремонт cred,
который был фактически установлен → подтверждение → и только затем воздействие). ADB=/SER=/
BIN_LOCAL= переопределяемы; SAME_VALUE=1 (по умолчанию) обеспечивает правило одного значения,
LAUNDER=1 включает шлюзованное отмывание, HOLD=600 требуется для последовательности
с одним значением.
Повторы разделены по этапам (R5/R6), потому что у двух этапов противоположные
профили риска:
R5 по умолчанию равен ROUNDS, R6 — 1.
Рекомендуемый запуск отмывания — путь с распылённой страницей по умолчанию, не
CONTROL=1:```bash
LAUNDER=1 R5=3 R6=1 HOLD=600 CHAINWAIT=6000 NODRAIN=1 WATCH=180 ./run_bootA.sh
`CONTROL=1` также дал бы согласованную пару, но записывая указатель `init_cred`, побочный эффект которого **глобально** повреждает `init_cred+8` — а «фреймворк умирает» — одна из вещей, за которыми ведётся наблюдение, поэтому перенос отказа уровня всего устройства в фон измерения искажает именно то показание, ради которого существует этот запуск. Путь через засеянную страницу стоит лишь «оба выстрела должны попасть», для чего и предназначен `R5=3`. `CONTROL=1` остаётся единственной *доказанной* согласованной парой и служит контролем, а не рекомендуемой конфигурацией.
**Какая страница была установлена, считывается из безусловной строки.** `run_w7` печатает записываемое значение на двух строках, и только одна из них безусловна:```
L1793 W7[..] write value = private cred page 0x.. — spray path only
L1802 W7[..] write_value = 0x.. — after the if/else, ALL paths
Раннер использовал первое написание, поэтому при CONTROL=1 извлечение
возвращалось пустым, if [ -n "$CRED" ] пропускал восстановление, и
собственный терм [ -n "$CRED" ] конъюнкции с тем же значением удерживал его на 0 — шлюз
отмывания отказал бы навсегда. Тихий no-op по обоим пунктам, из-за регулярного выражения, которое распознавало
один из двух мест вывода. И оно, и извлечение write_target (the stamp
criterion's input) теперь проходят через wv_from/wt_from, которые проверяются
регрессионным тестом наряду с самим критерием.
Оба потока начинаются до того, что они измеряют. uid.stream работает со
стадии 1; cred.stream начинается на poke, а не после watch — poke
выпускает дочерний процесс в его цикл отчётов NO_EXEC, который составляет 240 × 0.5 с = 120 с и
затем _exit(0) (exploit.c: "LT child NO-EXEC mode done (120s)"), поэтому старое
размещение на t+~135 с начинало выборку после того, как дочерний процесс уже завершился, в
точности в том окне, для которого существует инструмент. Раннер также отказывается продолжать,
если stamp_selftest() завершается неудачей, и печатает ORACLE INCONSISTENT, а не "did
not land", когда stamp и probe_state расходятся.
artifacts/guard_post_handler.s — цепочка уничтожения с заполненными релокациями. adrp x9, #0 в более старых листингах —
это .data..ro_after_init; bl #0x4ac — это oplus_root_check_succ. Перегенерируйте с помощью
tools/gen_guard_disasm.py после извлечения модулей вендора с вашего собственного устройства.
tools/kdis_ko.py — RELA сопоставляется по sh_info; в этих сборках релокации .text находятся в .rela.text.<func>, поэтому поиск по имени ничего не возвращает.
| Project | |
|---|---|
| JoinChang/ghostlock-oneplus | reference implementation; 5.10 compact waiter |
| NebuSec CyberMeowfia | original GhostLock research |
GPL-3.0 — см. LICENSE.
| Устройство | OPPO Find X5 Pro (PFEM10) |
| SoC | SM8450 / Adreno 730 |
| ОС | ColorOS 16.0.3.520 (CN01) |
| Ядро | 5.10.236-android12-9-o-gaf2075ad2c06 |
| Загрузчик | заблокирован, зелёный |
| VA_BITS | 39 — KIMAGE_TEXT_BASE = 0xffffffc008000000 |
| Этап |
|---|
Триггер компактного waiter (CMP_REQUEUE_PI → EDEADLK) | работает |
Утечка task_struct (perf) | работает |
Запись PI (8 байт; значение = 0 или валидный адрес ядра) | работает |
task+0x778 или task+0x780 по отдельности → Uid=root | работает — но приземление на одно поле оставляет задачу расходящейся, и это скрытый жёсткий BUG_ON. См. опасность расхождения |
| Оба поля записаны ОДНИМ значением (согласованная пара) | ❌ никогда не получено с распылённой страницей. Наблюдалось только с глобальным псевдонимом init_cred (09-14, CONTROL=1). Раннер теперь это принудительно обеспечивает (SAME_VALUE=1); на устройстве не запускалось |
Отмывание учётных данных (setresgid + setresuid) | реализовано за V12_LAUNDER=1; на устройстве не запускалось |
kernelsu.ko загружен | работает |
| Корневой процесс выживает | ⚠ не установлено — см. ниже |
| Механизм перезагрузки | ❌ не установлен. Один кандидат (расхождение) теперь исключён; см. ниже |
probe_state как критерий приземления | ❌ неверно — не использовать. Три контрпримера; см. таблицу ниже |
| Канал паники pstore/ramoops | ⚠ инструмент существует; канал никогда не проверялся (нулевого теста ещё нет) |
| «Жертва крутится в чистом userspace» | ⚠ показаний пока нет — uid.stream теперь записывает utime/stime/nvcsw, так что это можно проверить |
| Двойная запись за один проход со стороны pi | ⚠ не установлено; pi.pc/pi.left жёстко заданы как 0 в fdset_map.h |
Путь A (UMH / modprobe_path) | STATIC_USERMODEHELPER_PATH="" |
| Поле | Смещение |
|---|
real_cred / cred | 0x778 / 0x780 |
кэшированный syscallno | 0xdf8 |
кэшированные uid / euid / gid / egid | 0xe00 / 0xe08 / 0xe10 / 0xe18 |
flags0x0 |
addr_limit | 0x8 |
ttbr0 | 0x10 |
preempt_count | 0x18 |
| Поле | Смещение | Поле | Смещение |
|---|
uid | 0x4 | cap_inheritable | 0x28 |
gid | 0x8 | cap_permitted | 0x30 |
suid | 0xc | cap_effective | 0x38 |
sgid | 0x10 | cap_bset | 0x40 |
euid | 0x14 | cap_ambient | 0x48 |
egid | 0x18 | ||
fsuid / fsgid | 0x1c / 0x20 |
Uid: 0 0 0 0credstatus_gidreal_credlt_cred_ids_agree()| цель | оракул приземления |
|---|
task+0x778 | Uid: 4-е поле awk = hi32(write_target) и Gid: 2-е поле awk = low32(write_target) — метка выше; читать до этапа 3 |
task+0x780 | собственный getuid() жертвы |
глобальная selinux_enforcing | getenforce |
probe_state | ❌ не критерий. В лучшем случае подсказка о цепочке; никогда не доказательство того, что запись приземлилась |
$4notes.md$3euid0hi32(write_target)stamp_ok()run_bootA.sh| # | Хук | Триггер | Действие |
|---|
| 1 | oplus_root_check_post_handler, tracepoint sys_exit | какой-то id понизился, или addr_limit == KERNEL_DS | oplus_root_killed → printk + do_exit(SIGKILL); и oplus_root_check_succ → kevent_send_to_user |
| 2 | oplus_exe_block_ret_handler, sys_exit, но только для execve (221) | d_path(mm->exe_file) начинается с /data, /data/local/tmp, /data/nativetest, /data/nativetest64 | oplus_RWO_root_check → printk + kevent_send_to_user (без do_exit) |
| 3 | kretprobes oplus_secure_harden | setsockopt optname ∈ {41,42,48}, setxattr, /proc/cpuinfo, перезагрузка политики SELinux | oplus_heapspray_check → kevent_send_to_user |
143 setregid | 144 setgid | 145 setreuid | 146 setuid |
|---|
147 setresuid | 149 setresgid | 203 connect | 204 getsockname |
208 setsockopt | 210 shutdown | 213 readahead | 214 brk |
| kretprobe | Хуки | Фильтр |
|---|
socket_kretprobe | ip_setsockopt | regs[1] ∈ {41, 42, 48} |
socket_ip6_kretprobe | do_ipv6_setsockopt | regs[1] ∈ {41, 42} |
cpuinfo_kretprobe | cpuinfo_open | — |
setxattr_kretprobe | setxattr | — |
sepolicy_reload_kretprobe | spolicy_reload | — |
| ldr w8, [x1, #8] ; regs[1] | ||
| cmp w8, #0x29 ; 41 IP_MSFILTER | ||
| b.eq #0xd58 | ||
| cmp w8, #0x30 ; 48 MCAST_MSFILTER | ||
| b.eq #0xd60 | ||
| cmp w8, #0x2a ; 42 MCAST_JOIN_GROUP | ||
| b.ne #0xd68 ; else → return, no call | ||
| bl oplus_heapspray_check |
| этап | безопасность повтора |
|---|
R5 | шаг 5, task+0x778 | безопасно — промах ничего не устанавливает, а критерий штампа делает неудачный раунд читаемым, так что ещё один выстрел — это просто ещё одна попытка. R5=3 поднимает вероятность попадания за выстрел с ~p до ~1−(1−p)³. |
R6 | шаг 6, task+0x780 | не безопасно и не нужно — он срабатывает только после того, как шаг 5 попал, так что повтор стреляет по уже разошедшейся задаче: ещё один шанс записать вторую, другую страницу, без выгоды, поскольку одно попадание завершает пару. Оставьте его равным 1. |