From fc8066459a6a3aa9d1f7a47ef239db7d87ff2c1d Mon Sep 17 00:00:00 2001 From: okhsunrog Date: Tue, 11 Aug 2026 01:35:27 +0300 Subject: [PATCH] fix(kpm): diagnose standalone module installs --- Cargo.lock | 5 + Cargo.toml | 1 + ...d-the-app-now-detects-when-vpnhide-a24d.md | 9 ++ crates/activator/Cargo.toml | 1 + crates/activator/src/kpm.rs | 49 +-------- crates/activator/src/lib.rs | 17 +-- crates/activator/src/tests.rs | 8 +- crates/apatch-abi/Cargo.toml | 10 ++ crates/apatch-abi/src/lib.rs | 99 +++++++++++++++++ docs/diagnostics.md | 11 ++ docs/state.md | 11 +- docs/storage.md | 2 + lsposed/app/build.gradle.kts | 2 +- .../dev/okhsunrog/vpnhide/DashboardData.kt | 100 ++---------------- .../dev/okhsunrog/vpnhide/GroundTruthProbe.kt | 3 + .../dev/okhsunrog/vpnhide/KpmDiagnostics.kt | 100 ++++++++++++++++++ .../dev/okhsunrog/vpnhide/MainActivity.kt | 4 +- .../okhsunrog/vpnhide/RootSnapshotCache.kt | 44 +++++++- .../app/src/main/res/values-ru/strings.xml | 1 + lsposed/app/src/main/res/values/strings.xml | 1 + .../okhsunrog/vpnhide/NativeBackendTest.kt | 21 ++++ .../vpnhide/RootSnapshotCacheTest.kt | 22 ++++ lsposed/native/Cargo.lock | 5 + lsposed/native/Cargo.toml | 1 + lsposed/native/src/bin/vhprobe.rs | 92 ++++++++++++++++ 25 files changed, 457 insertions(+), 162 deletions(-) create mode 100644 changelog.d/fixed-the-app-now-detects-when-vpnhide-a24d.md create mode 100644 crates/apatch-abi/Cargo.toml create mode 100644 crates/apatch-abi/src/lib.rs create mode 100644 lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/KpmDiagnostics.kt diff --git a/Cargo.lock b/Cargo.lock index 0eaaff0e..20474f7a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -515,9 +515,14 @@ version = "1.1.1" dependencies = [ "serde", "serde_json", + "vpnhide_apatch_abi", "vpnhide_protocol", ] +[[package]] +name = "vpnhide_apatch_abi" +version = "1.1.1" + [[package]] name = "vpnhide_protocol" version = "1.1.1" diff --git a/Cargo.toml b/Cargo.toml index b149c38e..c3dd1806 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] resolver = "3" members = [ + "crates/apatch-abi", "crates/activator", "crates/protocol", "crates/protocol-diff", diff --git a/changelog.d/fixed-the-app-now-detects-when-vpnhide-a24d.md b/changelog.d/fixed-the-app-now-detects-when-vpnhide-a24d.md new file mode 100644 index 00000000..1870773e --- /dev/null +++ b/changelog.d/fixed-the-app-now-detects-when-vpnhide-a24d.md @@ -0,0 +1,9 @@ +_2026-08-11_ + +## English + +The app now detects when vpnhide.kpm was installed by itself instead of the complete KPM module ZIP and explains how to repair the installation. + +## Русский + +Приложение теперь определяет, когда vpnhide.kpm установлен отдельно вместо полного ZIP-модуля KPM, и объясняет, как исправить установку. diff --git a/crates/activator/Cargo.toml b/crates/activator/Cargo.toml index 5b7b3e21..5fad9bc3 100644 --- a/crates/activator/Cargo.toml +++ b/crates/activator/Cargo.toml @@ -12,6 +12,7 @@ name = "vpnhide_activator" [dependencies] serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +vpnhide_apatch_abi = { path = "../apatch-abi" } vpnhide_protocol = { path = "../protocol" } [[bin]] diff --git a/crates/activator/src/kpm.rs b/crates/activator/src/kpm.rs index 40e738c9..711bdbe6 100644 --- a/crates/activator/src/kpm.rs +++ b/crates/activator/src/kpm.rs @@ -388,25 +388,7 @@ fn apatch_output_len(rc: c_long, out: &[u8]) -> usize { } pub(crate) fn apatch_command_candidates() -> Vec { - let mut styles = Vec::new(); - if let Some(version) = apatch_kernel_version_hint() { - push_apatch_style(&mut styles, ApatchCommandStyle::Versioned(version)); - } - push_apatch_style( - &mut styles, - ApatchCommandStyle::Versioned(APATCH_SUPERCALL_DEFAULT_VERSION_CODE), - ); - for version in APATCH_SUPERCALL_VERSION_FALLBACKS { - push_apatch_style(&mut styles, ApatchCommandStyle::Versioned(*version)); - } - push_apatch_style(&mut styles, ApatchCommandStyle::Raw); - styles -} - -fn push_apatch_style(styles: &mut Vec, style: ApatchCommandStyle) { - if !styles.contains(&style) { - styles.push(style); - } + apatch_command_candidates_for_hint(apatch_kernel_version_hint()) } fn apatch_kernel_version_hint() -> Option { @@ -417,35 +399,6 @@ fn apatch_kernel_version_hint() -> Option { parse_apatch_kernel_version_hint(&String::from_utf8_lossy(&out.stdout)) } -pub(crate) fn parse_apatch_kernel_version_hint(log: &str) -> Option { - const MARKER: &str = "KP KernelPatch Version:"; - for line in log.lines().rev() { - let Some((_, tail)) = line.split_once(MARKER) else { - continue; - }; - let Some(value) = tail.split_whitespace().next() else { - continue; - }; - let value = value.trim_start_matches("0x"); - if value.is_empty() { - continue; - } - if let Ok(version) = c_long::from_str_radix(value, 16) { - return Some(version); - } - } - None -} - -pub(crate) fn supercall_cmd(style: ApatchCommandStyle, cmd: c_long) -> c_long { - match style { - ApatchCommandStyle::Versioned(version) => { - (version << 32) | (APATCH_SUPERCALL_MAGIC << 16) | (cmd & 0xffff) - } - ApatchCommandStyle::Raw => cmd, - } -} - fn supercall_ok(rc: c_long, op: &str) -> Result<()> { if rc >= 0 { Ok(()) diff --git a/crates/activator/src/lib.rs b/crates/activator/src/lib.rs index f34ffe83..a91b7ea1 100644 --- a/crates/activator/src/lib.rs +++ b/crates/activator/src/lib.rs @@ -20,6 +20,11 @@ use std::time::SystemTime; use std::time::UNIX_EPOCH; use serde::Deserialize; +use vpnhide_apatch_abi::{ + APATCH_SUPERCALL_NR, CommandStyle as ApatchCommandStyle, + command_candidates as apatch_command_candidates_for_hint, encode_command as supercall_cmd, + parse_kernel_version_hint as parse_apatch_kernel_version_hint, +}; use vpnhide_protocol::Target; use vpnhide_protocol::hook_ids::{HOOK_NAMES, KERNEL_HOOK_MASK, ZYGISK_HOOK_MASK}; use vpnhide_protocol::{Kind, MAX_TARGET_UIDS, format_config, parse_config, peek_kind}; @@ -59,18 +64,12 @@ const KPM_SUPPORTED_KERNEL_PAIRS: &[(u32, u32)] = &[ // lock-step instead of restating the literal 64. const MAX_NATIVE_TARGETS: usize = MAX_TARGET_UIDS; const PM_READY_ATTEMPTS: u32 = 60; -const APATCH_SUPERCALL_NR: c_long = 45; -const APATCH_SUPERCALL_DEFAULT_VERSION_CODE: c_long = 0x000d00; -const APATCH_SUPERCALL_MAGIC: c_long = 0x1158; const APATCH_TRUSTED_SU_KEY: &str = "su"; const SUPERCALL_HELLO: c_long = 0x1000; const SUPERCALL_HELLO_MAGIC: c_long = 0x11581158; const SUPERCALL_KPM_LOAD: c_long = 0x1020; const SUPERCALL_KPM_CONTROL: c_long = 0x1022; const SUPERCALL_KPM_LIST: c_long = 0x1031; -const APATCH_SUPERCALL_VERSION_FALLBACKS: &[c_long] = &[ - 0x000d02, 0x000d01, 0x000c02, 0x000c01, 0x000c00, 0x000b01, 0x000b00, 0x000a05, -]; unsafe extern "C" { fn syscall(num: c_long, ...) -> c_long; @@ -79,12 +78,6 @@ unsafe extern "C" { const LOCK_EX: c_int = 2; -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum ApatchCommandStyle { - Versioned(c_long), - Raw, -} - #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum PmReadyWait { Bounded(u32), diff --git a/crates/activator/src/tests.rs b/crates/activator/src/tests.rs index 8d0f1493..23fc13c8 100644 --- a/crates/activator/src/tests.rs +++ b/crates/activator/src/tests.rs @@ -439,11 +439,13 @@ fn pm_ready_check_matches_literal_package_token() { fn apatch_supercall_command_keeps_kpm_command_in_low_bits() { assert_eq!( supercall_cmd( - ApatchCommandStyle::Versioned(APATCH_SUPERCALL_DEFAULT_VERSION_CODE), + ApatchCommandStyle::Versioned( + vpnhide_apatch_abi::APATCH_SUPERCALL_DEFAULT_VERSION_CODE + ), SUPERCALL_KPM_CONTROL, ), - (APATCH_SUPERCALL_DEFAULT_VERSION_CODE << 32) - | (APATCH_SUPERCALL_MAGIC << 16) + (vpnhide_apatch_abi::APATCH_SUPERCALL_DEFAULT_VERSION_CODE << 32) + | (vpnhide_apatch_abi::APATCH_SUPERCALL_MAGIC << 16) | SUPERCALL_KPM_CONTROL, ); assert_eq!( diff --git a/crates/apatch-abi/Cargo.toml b/crates/apatch-abi/Cargo.toml new file mode 100644 index 00000000..00415a9c --- /dev/null +++ b/crates/apatch-abi/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "vpnhide_apatch_abi" +version = "1.1.1" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Shared APatch and FolkPatch supercall command compatibility." + +[lib] +name = "vpnhide_apatch_abi" diff --git a/crates/apatch-abi/src/lib.rs b/crates/apatch-abi/src/lib.rs new file mode 100644 index 00000000..1151ec7a --- /dev/null +++ b/crates/apatch-abi/src/lib.rs @@ -0,0 +1,99 @@ +//! KernelPatch/APatch supercall command compatibility shared by the KPM +//! activator and the APK's read-only runtime probe. + +pub const APATCH_SUPERCALL_NR: i64 = 45; +pub const APATCH_SUPERCALL_DEFAULT_VERSION_CODE: i64 = 0x000d00; +pub const APATCH_SUPERCALL_MAGIC: i64 = 0x1158; +pub const APATCH_SUPERCALL_VERSION_FALLBACKS: &[i64] = &[ + 0x000d02, 0x000d01, 0x000c02, 0x000c01, 0x000c00, 0x000b01, 0x000b00, 0x000a05, +]; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CommandStyle { + Versioned(i64), + Raw, +} + +pub fn command_candidates(version_hint: Option) -> Vec { + let mut styles = Vec::new(); + for style in version_hint + .map(CommandStyle::Versioned) + .into_iter() + .chain(std::iter::once(CommandStyle::Versioned( + APATCH_SUPERCALL_DEFAULT_VERSION_CODE, + ))) + .chain( + APATCH_SUPERCALL_VERSION_FALLBACKS + .iter() + .copied() + .map(CommandStyle::Versioned), + ) + .chain(std::iter::once(CommandStyle::Raw)) + { + if !styles.contains(&style) { + styles.push(style); + } + } + styles +} + +pub fn encode_command(style: CommandStyle, command: i64) -> i64 { + match style { + CommandStyle::Versioned(version) => { + (version << 32) | (APATCH_SUPERCALL_MAGIC << 16) | (command & 0xffff) + } + CommandStyle::Raw => command, + } +} + +pub fn parse_kernel_version_hint(log: &str) -> Option { + const MARKER: &str = "KP KernelPatch Version:"; + log.lines().rev().find_map(|line| { + let (_, tail) = line.split_once(MARKER)?; + let value = tail + .trim_start() + .strip_prefix("0x") + .unwrap_or_else(|| tail.trim_start()) + .split(|character: char| !character.is_ascii_hexdigit()) + .next()?; + if value.is_empty() { + return None; + } + i64::from_str_radix(value, 16).ok() + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn candidates_cover_hint_fallbacks_and_raw_abi_once() { + let candidates = command_candidates(Some(0x000d01)); + assert_eq!(candidates.first(), Some(&CommandStyle::Versioned(0x000d01))); + assert_eq!(candidates.last(), Some(&CommandStyle::Raw)); + assert_eq!( + candidates + .iter() + .filter(|style| **style == CommandStyle::Versioned(0x000d01)) + .count(), + 1 + ); + } + + #[test] + fn encoding_keeps_magic_and_low_command_bits() { + assert_eq!( + encode_command(CommandStyle::Versioned(0x000d03), 0x1031), + (0x000d03_i64 << 32) | (0x1158_i64 << 16) | 0x1031, + ); + assert_eq!(encode_command(CommandStyle::Raw, 0x1031), 0x1031); + } + + #[test] + fn parses_latest_version_hint_from_dmesg() { + let log = + "old\nKP KernelPatch Version: 000d02\nnoise\nKP KernelPatch Version: 000d03-extra\n"; + assert_eq!(parse_kernel_version_hint(log), Some(0x000d03)); + } +} diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 7b430039..0777398e 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -43,6 +43,17 @@ and execs it via `su`; it emits the same JSON as the in-process JNI path replaced an earlier gobley/UniFFI binding — the whole native surface is now one JSON-returning function built with plain cargo-ndk.) +The same Rust executable also has a read-only `--apatch-kpm-list` mode used by +the dashboard's installation-integrity check. Together with `kpatch kpm list` +on KPatch-Next, it detects a runtime-loaded `vpnhide` KPM when the +`/data/adb/modules/vpnhide_kpm` flashable module is absent. That combination +indicates that the user loaded or embedded the inner `vpnhide.kpm` file without +installing the complete `vpnhide-kpm.zip`; the app explains that the raw file +lacks the activator, boot scripts, and config delivery, then directs the user to +remove the raw KPM, install the ZIP through the root manager's Modules screen, +and reboot. APatch authentication uses the saved root-only SuperKey or its +trusted `su` token; the probe never prints either credential. + ## 3. Per-check outcome (`CheckOutcome`) `Leak` · `HiddenByBackend` · `HiddenBySelinux` · `NothingToLeak` · diff --git a/docs/state.md b/docs/state.md index d9e3cf6c..7c25b1c8 100644 --- a/docs/state.md +++ b/docs/state.md @@ -271,6 +271,15 @@ the app when Vector is active. - Reader: app root snapshot/dashboard/startup cleanup. - Lifetime: per app launch, stale records removed when boot_id changes. +`/data/user/0/dev.okhsunrog.vpnhide/files/vhprobe` + +- Format: executable Rust diagnostic probe extracted from the APK asset. +- Writer/reader: the app overwrites it on launch; root diagnostics copy it to + `/data/local/tmp/vpnhide_vhprobe` for root-differential checks or to the + per-process `/data/local/tmp/vpnhide_kpm_probe.` for APatch KPM listing. +- Lifetime: app-private and replaced on launch. Every `/data/local/tmp` copy is + deleted immediately after execution. + ### `cacheDir` Used for temporary debug/export files and a copied read-only LSPosed config DB: @@ -342,7 +351,7 @@ zygote app fork: |---|---| | In-kernel per boot | `/proc/vpnhide_ctl` state, KPM in-kernel state, iptables chains | | Per boot / last apply files | `/data/adb/vpnhide_kmod/load_status`, `/data/adb/vpnhide_kmod/load_dmesg`, `/data/adb/vpnhide_kpm/load_status`, `/data/adb/vpnhide_ports/load_status`, `/data/adb/vpnhide_ports/load_log`, `/data/system/vpnhide_lsposed_state` | -| Per app launch | `filesDir/vpnhide_zygisk_active` | +| Per app launch | `filesDir/vpnhide_zygisk_active`, `filesDir/vhprobe` | | Persistent root-managed | `/data/system/vpnhide_config.json`, `/data/adb/vpnhide/superkey` | | Module-dir derived state | `/data/adb/modules/vpnhide_zygisk/targets.txt` | | Removed on module uninstall | `/data/adb/vpnhide_kmod/`, `/data/adb/vpnhide_kpm/`, `/data/adb/vpnhide_zygisk/`, `/data/adb/vpnhide_ports/` when empty after deleting module-specific files | diff --git a/docs/storage.md b/docs/storage.md index f19b5e53..2f6b553d 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -205,6 +205,8 @@ wire. So: one shared core, thin per-target front-ends. ``` crates/ + apatch-abi/ # read-only/load supercall command compatibility, + # shared by the KPM activator and APK probe. protocol/ # lib, NO serde — the wire (parse/format), shared by # the Zygisk .so and the activator. activator/ diff --git a/lsposed/app/build.gradle.kts b/lsposed/app/build.gradle.kts index cbb80b92..e43e696f 100644 --- a/lsposed/app/build.gradle.kts +++ b/lsposed/app/build.gradle.kts @@ -65,7 +65,7 @@ detekt { // default ceiling. Remove an entry once a file drops below the default. val kotlinSourceLineBudgets = mapOf( - "DashboardData.kt" to 1789, + "DashboardData.kt" to 1716, "DashboardScreen.kt" to 1335, "HookEntry.kt" to 1276, "SettingsScreen.kt" to 1201, diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/DashboardData.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/DashboardData.kt index 53e27514..c2158ab6 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/DashboardData.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/DashboardData.kt @@ -830,94 +830,6 @@ private fun renderKmodProblem( }, ) -internal sealed interface KpmProblemKind { - val reason: ModuleBrokenReason? - - data class UnsupportedKernel( - val unameR: String, - ) : KpmProblemKind { - override val reason get() = ModuleBrokenReason.UnsupportedKernel - } - - // The activator binary itself is missing from the module directory — a - // corrupted or partial KPM install (see kmod/kpm/module/service.sh). - // [path] is the path the boot script tried to exec. - data class ActivatorMissing( - val path: String, - ) : KpmProblemKind { - override val reason get() = ModuleBrokenReason.KpmActivatorMissing - } - - // The activator ran but exited non-zero for a reason we don't have a - // named diagnosis for. [detail] is the raw `rc= ` - // the boot script wrote — the KPM analogue of kmod's raw insmod stderr. - // Null reason (mirrors KmodProblemKind.LoadFailed): an untriaged exit - // code isn't a confident enough diagnosis to paint the module card red. - data class LoadFailed( - val detail: String, - ) : KpmProblemKind { - override val reason: ModuleBrokenReason? get() = null - } -} - -/** - * Diagnose a KPM install that's present but didn't load in either activator - * path: service-time APatch/FolkPatch or post-fs-data KPatch-Next. Conflict and - * awaiting-authentication are recoverable states handled by their dedicated - * warnings. All other fresh failures use the stable `reason` field when known - * and retain the raw detail as a fallback for older module builds. - */ -internal fun classifyKpmProblem( - kpm: ModuleState, - loadStatusSection: String, - currentBootId: String, -): KpmProblemKind? { - if (kpm !is ModuleState.Installed || kpm.active) return null - val load = parseKeyValueLines(loadStatusSection) - val bootId = load["boot_id"]?.trim() - val runtime = load["runtime"]?.trim() - if (runtime !in setOf("activator", "kpatch-next") || - load["loaded"]?.trim() != "0" || - bootId.isNullOrEmpty() || - bootId != currentBootId.trim() - ) { - return null - } - val reason = load["reason"]?.trim() - if (reason == "unsupported_kernel") { - return KpmProblemKind.UnsupportedKernel(load["uname_r"]?.trim().orEmpty().ifBlank { "?" }) - } - val detail = load["detail"]?.trim().orEmpty() - val missingPrefix = "activator missing at " - return if (reason == "missing_activator" || detail.startsWith(missingPrefix)) { - KpmProblemKind.ActivatorMissing(detail.removePrefix(missingPrefix)) - } else { - KpmProblemKind.LoadFailed(detail) - } -} - -private fun renderKpmProblem( - kind: KpmProblemKind, - res: android.content.res.Resources, -): ModuleProblem = - ModuleProblem( - reason = kind.reason, - text = - when (kind) { - is KpmProblemKind.UnsupportedKernel -> { - res.getString(R.string.dashboard_issue_kpm_unsupported_kernel, kind.unameR) - } - - is KpmProblemKind.ActivatorMissing -> { - res.getString(R.string.dashboard_issue_kpm_activator_missing, kind.path) - } - - is KpmProblemKind.LoadFailed -> { - res.getString(R.string.dashboard_issue_kpm_load_failed, kind.detail) - } - }, - ) - // ── LSPosed state resolution (pure, unit-tested) ───────────────────────── /** @@ -1289,11 +1201,15 @@ internal suspend fun loadDashboardState( val zygiskStatusRaw = shellSnapshot["zygisk_status"].orEmpty() val zygisk = rawNativeBackends.zygisk.withTargetCount(nativeTargetCount) val kpmRaw = rawNativeBackends.kpm.withTargetCount(nativeTargetCount) + val standaloneKpm = standaloneKpmLoaded(kpmRaw, shellSnapshot["kpm_runtime_modules"].orEmpty()) val ports = detectPortsModule(shellSnapshot, selfPkg).withTargetCount(countPackages(targetsSnapshot.portsObservers)) val kmodTargetCount = (kmodRaw as? ModuleState.Installed)?.targetCount ?: 0 val kpmTargetCount = (kpmRaw as? ModuleState.Installed)?.targetCount ?: 0 val zygiskTargetCount = (zygisk as? ModuleState.Installed)?.targetCount ?: 0 - VpnHideLog.i(TAG, "modules: kmodRaw=$kmodRaw kpmRaw=$kpmRaw zygisk=$zygisk ports=$ports") + VpnHideLog.i( + TAG, + "modules: kmodRaw=$kmodRaw kpmRaw=$kpmRaw standaloneKpm=$standaloneKpm zygisk=$zygisk ports=$ports", + ) StartupTrace.mark("dashboard_modules_done") // Recommendation based purely on the kernel — used by the install card, @@ -1352,7 +1268,7 @@ internal suspend fun loadDashboardState( // installed yet. Wrong-variant / broken / unsupported-kernel cases // already emit a red error below with the same CTA — showing both // duplicates the instruction. - val nativeInstallRecommendation = kernelRecommendation?.takeIf { backends.noneInstalled } + val nativeInstallRecommendation = kernelRecommendation?.takeIf { backends.noneInstalled && !standaloneKpm } VpnHideLog.i( TAG, "nativeInstallRecommendation=$nativeInstallRecommendation " + @@ -1413,7 +1329,9 @@ internal suspend fun loadDashboardState( // ── Messages ── val hasNative = backends.anyInstalled - if (!hasNative) { + if (standaloneKpm) { + err(res.getString(R.string.dashboard_issue_kpm_standalone_install), "vpnhide-kpm.zip") + } else if (!hasNative) { err(res.getString(R.string.dashboard_issue_no_native)) } if (lsposedFramework is LsposedFramework.NotInstalled && lsposed !is LsposedState.Active) { diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/GroundTruthProbe.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/GroundTruthProbe.kt index 1846f889..f036cbab 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/GroundTruthProbe.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/GroundTruthProbe.kt @@ -56,6 +56,9 @@ object GroundTruthProbe { return runCatching { org.json.JSONObject(json).getBoolean("routed") }.getOrNull() } + /** Prepare the shared root-executable probe for batched runtime checks. */ + fun prepare(context: Context): File? = extractBinary(context) + private fun extractBinary(context: Context): File? = runCatching { val dest = File(context.filesDir, "vhprobe") diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/KpmDiagnostics.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/KpmDiagnostics.kt new file mode 100644 index 00000000..06602e9e --- /dev/null +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/KpmDiagnostics.kt @@ -0,0 +1,100 @@ +package dev.okhsunrog.vpnhide + +internal sealed interface KpmProblemKind { + val reason: ModuleBrokenReason? + + data class UnsupportedKernel( + val unameR: String, + ) : KpmProblemKind { + override val reason get() = ModuleBrokenReason.UnsupportedKernel + } + + // The activator binary itself is missing from the module directory — a + // corrupted or partial KPM install (see kmod/kpm/module/service.sh). + // [path] is the path the boot script tried to exec. + data class ActivatorMissing( + val path: String, + ) : KpmProblemKind { + override val reason get() = ModuleBrokenReason.KpmActivatorMissing + } + + // Null reason mirrors kmod's raw LoadFailed fallback: an untriaged exit + // isn't a confident enough diagnosis to paint the module card red. + data class LoadFailed( + val detail: String, + ) : KpmProblemKind { + override val reason: ModuleBrokenReason? get() = null + } +} + +/** Diagnose a complete KPM installation that failed in either boot path. */ +internal fun classifyKpmProblem( + kpm: ModuleState, + loadStatusSection: String, + currentBootId: String, +): KpmProblemKind? { + if (kpm !is ModuleState.Installed || kpm.active) return null + val load = parseKeyValueLines(loadStatusSection) + val bootId = load["boot_id"]?.trim() + val runtime = load["runtime"]?.trim() + if (runtime !in setOf("activator", "kpatch-next") || + load["loaded"]?.trim() != "0" || + bootId.isNullOrEmpty() || + bootId != currentBootId.trim() + ) { + return null + } + val reason = load["reason"]?.trim() + if (reason == "unsupported_kernel") { + return KpmProblemKind.UnsupportedKernel(load["uname_r"]?.trim().orEmpty().ifBlank { "?" }) + } + val detail = load["detail"]?.trim().orEmpty() + val missingPrefix = "activator missing at " + return if (reason == "missing_activator" || detail.startsWith(missingPrefix)) { + KpmProblemKind.ActivatorMissing(detail.removePrefix(missingPrefix)) + } else { + KpmProblemKind.LoadFailed(detail) + } +} + +internal fun renderKpmProblem( + kind: KpmProblemKind, + res: android.content.res.Resources, +): ModuleProblem = + ModuleProblem( + reason = kind.reason, + text = + when (kind) { + is KpmProblemKind.UnsupportedKernel -> { + res.getString(R.string.dashboard_issue_kpm_unsupported_kernel, kind.unameR) + } + + is KpmProblemKind.ActivatorMissing -> { + res.getString(R.string.dashboard_issue_kpm_activator_missing, kind.path) + } + + is KpmProblemKind.LoadFailed -> { + res.getString(R.string.dashboard_issue_kpm_load_failed, kind.detail) + } + }, + ) + +/** + * A runtime-loaded `vpnhide` KPM without our flashable module directory is a + * raw `.kpm` installation. It lacks the activator and boot/config lifecycle, + * so it must not count as a valid native backend even if KernelPatch lists it. + */ +internal fun standaloneKpmLoaded( + kpm: ModuleState, + runtimeModulesSection: String, +): Boolean { + if (kpm is ModuleState.Installed) return false + val lines = + runtimeModulesSection + .lineSequence() + .map(String::trim) + .filter(String::isNotEmpty) + .toList() + if (lines.firstOrNull() != "available=1") return false + return lines.drop(1).flatMap { it.split(Regex("\\s+")) }.any { it == "vpnhide" } +} diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/MainActivity.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/MainActivity.kt index 5d824081..ce30cc62 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/MainActivity.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/MainActivity.kt @@ -68,8 +68,8 @@ class MainActivity : ComponentActivity() { StartupTrace.mark("activity_on_create") enableEdgeToEdge() super.onCreate(savedInstanceState) - // Load debug state from the canonical snapshot before any runtime work. - // This keeps first suExec and dashboard bootstrap aligned. + RootSnapshotCache.setRuntimeProbeSource(GroundTruthProbe.prepare(this)?.absolutePath) + // Load canonical debug state before runtime work so first suExec and dashboard bootstrap agree. VpnHideLog.init() setContent { VpnHideApp() diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/RootSnapshotCache.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/RootSnapshotCache.kt index 2522370c..fa0e7071 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/RootSnapshotCache.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/RootSnapshotCache.kt @@ -48,6 +48,7 @@ internal val REQUIRED_ROOT_SNAPSHOT_SECTIONS = "kernel_release", "kmod_state", "kpm_state", + "kpm_runtime_modules", "lsposed_state", "getenforce", "kpatch_runtime", @@ -73,6 +74,11 @@ internal object RootSnapshotCache { private val mutex = Mutex() private var preloadedPmPackages: String? = null + private var runtimeProbeSource: String? = null + + fun setRuntimeProbeSource(path: String?) { + runtimeProbeSource = path?.takeIf { it.matches(Regex("/[A-Za-z0-9_./-]+")) } + } suspend fun getOrLoad(): RootSnapshot = withContext(Dispatchers.IO) { @@ -105,7 +111,7 @@ internal object RootSnapshotCache { StartupTrace.mark("root_snapshot_start") val pmPackages = preloadedPmPackages preloadedPmPackages = null - val sections = loadRootShellSnapshot(pmPackagesOverride = pmPackages) + val sections = loadRootShellSnapshot(pmPackagesOverride = pmPackages, runtimeProbeSource = runtimeProbeSource) val snapshot = RootSnapshot(sections) _snapshot.value = snapshot StartupTrace.mark("root_snapshot_done") @@ -119,10 +125,16 @@ internal object RootSnapshotCache { } } -private fun loadRootShellSnapshot(pmPackagesOverride: String?): Map { +private fun loadRootShellSnapshot( + pmPackagesOverride: String?, + runtimeProbeSource: String?, +): Map { val (exitCode, raw) = suExec( - buildRootShellSnapshotCommand(includePmPackages = pmPackagesOverride == null), + buildRootShellSnapshotCommand( + includePmPackages = pmPackagesOverride == null, + runtimeProbeSource = runtimeProbeSource, + ), timeoutSec = ROOT_SNAPSHOT_TIMEOUT_SEC, ) if (exitCode != 0) { @@ -146,8 +158,12 @@ internal fun validateRootSnapshotSections(sections: Map) { // Long because it's a single embedded shell script (the batched root probe), // not Kotlin control flow. @Suppress("LongMethod") -internal fun buildRootShellSnapshotCommand(includePmPackages: Boolean = true): String = +internal fun buildRootShellSnapshotCommand( + includePmPackages: Boolean = true, + runtimeProbeSource: String? = null, +): String = """ + KPM_RUNTIME_PROBE_SOURCE=${runtimeProbeSource.orEmpty()} emit_cmd() { NAME="${'$'}1" shift @@ -238,6 +254,26 @@ internal fun buildRootShellSnapshotCommand(includePmPackages: Boolean = true): S emit_cmd kernel_release uname -r emit_eval kmod_state '[ -e $PROC_CTL ] && cat $PROC_CTL || true' emit_eval kpm_state 'if [ -x $KPM_ACTIVATOR ] && [ ! -f $KPM_MODULE_DIR/disable ]; then $KPM_ACTIVATOR state; fi' + emit_eval kpm_runtime_modules ' + KPATCH="" + for CANDIDATE in kpatch /data/adb/modules/KPatch-Next/bin/kpatch /data/adb/modules/kpatch-next/bin/kpatch; do + if command -v "${'$'}CANDIDATE" >/dev/null 2>&1; then KPATCH="${'$'}CANDIDATE"; break; fi + if [ -x "${'$'}CANDIDATE" ]; then KPATCH="${'$'}CANDIDATE"; break; fi + done + if [ -n "${'$'}KPATCH" ]; then + KPM_LIST="${'$'}("${'$'}KPATCH" kpm list 2>/dev/null)" + if [ ${'$'}? -eq 0 ]; then printf "available=1\\n%s\\n" "${'$'}KPM_LIST"; else echo available=0; fi + elif [ -d /data/adb/ap ] && [ -f "${'$'}KPM_RUNTIME_PROBE_SOURCE" ]; then + KPM_PROBE=/data/local/tmp/vpnhide_kpm_probe.${'$'}${'$'} + if cp "${'$'}KPM_RUNTIME_PROBE_SOURCE" "${'$'}KPM_PROBE" && chmod 700 "${'$'}KPM_PROBE"; then + "${'$'}KPM_PROBE" --apatch-kpm-list 2>/dev/null || echo available=0 + else + echo available=0 + fi + rm -f "${'$'}KPM_PROBE" + else + echo available=0 + fi' emit_file lsposed_state $LSPOSED_STATE_FILE emit_cmd getenforce getenforce # Is a KPM runtime present? Either APatch's native KernelPatch (loads KPMs diff --git a/lsposed/app/src/main/res/values-ru/strings.xml b/lsposed/app/src/main/res/values-ru/strings.xml index 155c89f0..a4b20dba 100644 --- a/lsposed/app/src/main/res/values-ru/strings.xml +++ b/lsposed/app/src/main/res/values-ru/strings.xml @@ -268,6 +268,7 @@ KPM установлен, но не активен: APatch/FolkPatch не смог загрузить его через доверенный su-токен. Сохраните SuperKey в Настройки → Безопасность и перезагрузитесь для активации нативного скрытия. Модуль KPM повреждён: отсутствует бинарник активатора (%1$s). Переустановите vpnhide-kpm.zip из последнего релиза. KPM установлен, но не может загрузиться на ядре %1$s: для этого семейства ядра нет проверенной таблицы смещений. Используйте Zygisk. + Обнаружена неправильная установка KPM: KernelPatch загрузил отдельный файл vpnhide.kpm, но модуль vpnhide-kpm.zip не установлен. В одном файле .kpm нет активатора VPN Hide, загрузочных скриптов и доставки настроек, поэтому защита не будет работать правильно. Удалите или выгрузите отдельную запись «vpnhide» в APatch/KPatch-Next (если KPM был встроен в ядро, заново пропатчите boot-образ без него), установите целиком vpnhide-kpm.zip через экран «Модули» и перезагрузите устройство. KPM установлен, но не загрузился: %1$s. Чтобы приложить полный лог загрузки к отчёту об ошибке, нажмите Настройки → Отладка → Собрать отладочный лог, либо переустановите vpnhide-kpm.zip из последнего релиза. Установлено больше одного Native-бэкенда. Активным может быть только один (приоритет: модуль ядра, затем KPM, затем Zygisk), остальные простаивают. Удалите неиспользуемые, чтобы не засорять систему. Отладочные логи включены. VPN Hide пишет подробные строки в logcat, которые может прочитать любой с root-доступом. Выключите переключатель в Настройки → Отладка после сбора отчёта об ошибке. diff --git a/lsposed/app/src/main/res/values/strings.xml b/lsposed/app/src/main/res/values/strings.xml index 8aa4c5b8..92bc2477 100644 --- a/lsposed/app/src/main/res/values/strings.xml +++ b/lsposed/app/src/main/res/values/strings.xml @@ -298,6 +298,7 @@ The KPM is installed but inactive: APatch/FolkPatch could not load it with the trusted su token. Save your SuperKey in Settings → Security, then reboot to activate native hiding. KPM module is corrupted: the activator binary is missing (%1$s). Reinstall vpnhide-kpm.zip from the latest release. KPM is installed but cannot load on kernel %1$s because this kernel family has no validated offset table. Use the Zygisk backend instead. + Invalid KPM installation detected: KernelPatch loaded the standalone vpnhide.kpm file, but the vpnhide-kpm.zip module is not installed. The .kpm file alone has no VPN Hide activator, boot scripts, or configuration delivery, so protection will not work correctly. Remove or unload the standalone “vpnhide” entry in APatch/KPatch-Next (if it was embedded, repatch the boot image without it), install the complete vpnhide-kpm.zip from the Modules screen, then reboot. KPM installed but failed to load: %1$s. Use Settings → Debugging → Collect debug log to attach the full boot-time output to a bug report, or reinstall vpnhide-kpm.zip from the latest release. More than one native backend is installed. Only one can be active at a time (priority: kernel module, then KPM, then Zygisk); the others sit idle. Uninstall the ones you don\'t use to keep things clean. Debug logging is enabled. VPN Hide is writing verbose lines to logcat that anyone with root can read. Turn it off in Settings → Debugging after you\'ve finished collecting a bug report. diff --git a/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/NativeBackendTest.kt b/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/NativeBackendTest.kt index b4f551ff..15b897c6 100644 --- a/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/NativeBackendTest.kt +++ b/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/NativeBackendTest.kt @@ -1,7 +1,9 @@ package dev.okhsunrog.vpnhide import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test class NativeBackendTest { @@ -279,4 +281,23 @@ class NativeBackendTest { val state = detectKpmModule(sections, selfPkg = "self", currentBootId = "boot-1") as ModuleState.Installed assertEquals(false, state.active) } + + @Test + fun `standalone runtime KPM is detected without flashable module`() { + assertTrue(standaloneKpmLoaded(ModuleState.NotInstalled, "available=1\nvpnhide\n")) + assertTrue(standaloneKpmLoaded(ModuleState.NotInstalled, "available=1\n0 vpnhide loaded\n")) + } + + @Test + fun `runtime KPM is not standalone when flashable module is installed`() { + val installed = ModuleState.Installed(version = "1.0", active = true, targetCount = 0) + assertFalse(standaloneKpmLoaded(installed, "available=1\nvpnhide\n")) + } + + @Test + fun `unavailable runtime list or another KPM is not a standalone vpnhide install`() { + assertFalse(standaloneKpmLoaded(ModuleState.NotInstalled, "available=0\n")) + assertFalse(standaloneKpmLoaded(ModuleState.NotInstalled, "available=1\nother_module\n")) + assertFalse(standaloneKpmLoaded(ModuleState.NotInstalled, "available=1\nvpnhide_next\n")) + } } diff --git a/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/RootSnapshotCacheTest.kt b/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/RootSnapshotCacheTest.kt index 6806fc15..613c8e73 100644 --- a/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/RootSnapshotCacheTest.kt +++ b/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/RootSnapshotCacheTest.kt @@ -76,6 +76,7 @@ class RootSnapshotCacheTest { assertTrue(command.contains("[ -s $SUPERKEY_FILE ] && echo 1 || echo 0")) assertTrue(command.contains("cat $PROC_CTL")) assertTrue(command.contains("$KPM_ACTIVATOR state")) + assertTrue(command.contains("kpm_runtime_modules")) assertTrue(command.contains(ZYGISK_STATUS_FILE)) assertTrue(command.contains(PORTS_LOAD_STATUS_FILE)) assertTrue(command.contains("probe_ok=1")) @@ -90,6 +91,16 @@ class RootSnapshotCacheTest { assertFalse(command.contains("debug_logging")) } + @Test + fun `snapshot stages APatch runtime probe only from validated app path`() { + val command = buildRootShellSnapshotCommand(runtimeProbeSource = "/data/user/0/dev.okhsunrog.vpnhide/files/vhprobe") + + assertTrue(command.contains("KPM_RUNTIME_PROBE_SOURCE=/data/user/0/dev.okhsunrog.vpnhide/files/vhprobe")) + assertTrue(command.contains("--apatch-kpm-list")) + assertTrue(command.contains("\"${'$'}KPATCH\" kpm list")) + assertTrue(command.contains("rm -f \"${'$'}KPM_PROBE\"")) + } + @Test fun `snapshot command can skip package enumeration when startup seeded it`() { val command = buildRootShellSnapshotCommand(includePmPackages = false) @@ -98,4 +109,15 @@ class RootSnapshotCacheTest { assertTrue(command.contains("phase_target_files")) assertTrue(command.contains("phase_vpn_ifaces")) } + + @Test + fun `snapshot command remains valid POSIX shell with runtime probe`() { + val process = ProcessBuilder("sh", "-c", buildRootShellSnapshotCommand(includePmPackages = false)).start() + val stdout = process.inputStream.bufferedReader().readText() + val stderr = process.errorStream.bufferedReader().readText() + + assertEquals("shell stderr: $stderr", 0, process.waitFor()) + val sections = parseRootShellSnapshot(stdout, recordMetric = { _, _ -> }) + assertEquals("available=0", sections["kpm_runtime_modules"]) + } } diff --git a/lsposed/native/Cargo.lock b/lsposed/native/Cargo.lock index cff9a23c..9c479e21 100644 --- a/lsposed/native/Cargo.lock +++ b/lsposed/native/Cargo.lock @@ -235,6 +235,10 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "vpnhide_apatch_abi" +version = "1.1.1" + [[package]] name = "vpnhide_checks" version = "1.1.1" @@ -243,6 +247,7 @@ dependencies = [ "libc", "serde", "serde_json", + "vpnhide_apatch_abi", ] [[package]] diff --git a/lsposed/native/Cargo.toml b/lsposed/native/Cargo.toml index b2f0c55d..80b6fb58 100644 --- a/lsposed/native/Cargo.toml +++ b/lsposed/native/Cargo.toml @@ -19,6 +19,7 @@ path = "src/bin/vhprobe.rs" libc = "0.2" serde = { version = "1", features = ["derive"] } serde_json = "1" +vpnhide_apatch_abi = { path = "../../crates/apatch-abi" } # Minimal JNI surface: one exported function returning the checks as JSON. jni = { version = "0.22.4", default-features = false } diff --git a/lsposed/native/src/bin/vhprobe.rs b/lsposed/native/src/bin/vhprobe.rs index 4c3f33d1..b4173539 100644 --- a/lsposed/native/src/bin/vhprobe.rs +++ b/lsposed/native/src/bin/vhprobe.rs @@ -5,8 +5,28 @@ // `--uid `: the self-in-tunnel gate — report whether uid is routed // through the VPN (a policy rule steers it into a tun table). Emits // `{uid, routed, detail}` instead of the full checks array. +// +// `--apatch-kpm-list`: read-only APatch/FolkPatch KPM enumeration for detecting +// a raw vpnhide.kpm loaded without the flashable module. Authentication tries +// the root-only saved key and then APatch's trusted `su` token; neither value is +// printed. Emits `available=1` plus module names, or `available=0` when the +// runtime cannot be authenticated. + +use std::ffi::CString; +use std::os::raw::{c_long, c_void}; +use vpnhide_apatch_abi::{ + APATCH_SUPERCALL_NR, command_candidates, encode_command, parse_kernel_version_hint, +}; + +const SUPERKEY_FILE: &str = "/data/adb/vpnhide/superkey"; +const SUPERCALL_KPM_LIST: c_long = 0x1031; + fn main() { let args: Vec = std::env::args().collect(); + if args.get(1).is_some_and(|arg| arg == "--apatch-kpm-list") { + print_apatch_kpm_list(); + return; + } if let Some(i) = args.iter().position(|a| a == "--uid") { match args.get(i + 1).and_then(|s| s.parse::().ok()) { Some(uid) => println!("{}", vpnhide_checks::self_routed_json(uid)), @@ -19,3 +39,75 @@ fn main() { } println!("{}", vpnhide_checks::run_all_json()); } + +fn print_apatch_kpm_list() { + match apatch_kpm_list() { + Some(list) => { + println!("available=1"); + print!("{list}"); + if !list.is_empty() && !list.ends_with('\n') { + println!(); + } + } + None => println!("available=0"), + } +} + +fn apatch_kpm_list() -> Option { + let mut keys = Vec::new(); + if let Ok(saved) = std::fs::read_to_string(SUPERKEY_FILE) { + let saved = saved.trim(); + if !saved.is_empty() { + keys.push(saved.to_owned()); + } + } + if !keys.iter().any(|key| key == "su") { + keys.push("su".to_owned()); + } + + let commands = command_candidates(apatch_kernel_version_hint()); + for key in keys { + let key = CString::new(key).ok()?; + for &style in &commands { + let mut buffer = [0_u8; 4096]; + let rc = unsafe { + libc::syscall( + APATCH_SUPERCALL_NR, + key.as_ptr(), + encode_command(style, SUPERCALL_KPM_LIST), + buffer.as_mut_ptr().cast::(), + buffer.len() as c_long, + ) + }; + if rc >= 0 { + let length = + if rc > 0 { + usize::try_from(rc).unwrap_or(buffer.len()).min(buffer.len()) + } else { + buffer.iter().position(|byte| *byte == 0).unwrap_or(0) + }; + return Some(String::from_utf8_lossy(&buffer[..length]).into_owned()); + } + } + } + None +} + +fn apatch_kernel_version_hint() -> Option { + let output = std::process::Command::new("dmesg").output().ok()?; + output + .status + .success() + .then(|| parse_kernel_version_hint(&String::from_utf8_lossy(&output.stdout)))? +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_latest_apatch_version_hint_from_dmesg() { + let log = "old\nKP KernelPatch Version: 000d02\nnoise\nKP KernelPatch Version: 000d03-extra\n"; + assert_eq!(parse_kernel_version_hint(log), Some(0x000d03)); + } +}