fix(kpm): diagnose standalone module installs

This commit is contained in:
okhsunrog 2026-08-11 01:35:27 +03:00
parent 7cabe84758
commit fc8066459a
25 changed files with 457 additions and 162 deletions

5
Cargo.lock generated
View file

@ -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"

View file

@ -1,6 +1,7 @@
[workspace]
resolver = "3"
members = [
"crates/apatch-abi",
"crates/activator",
"crates/protocol",
"crates/protocol-diff",

View file

@ -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, и объясняет, как исправить установку.

View file

@ -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]]

View file

@ -388,25 +388,7 @@ fn apatch_output_len(rc: c_long, out: &[u8]) -> usize {
}
pub(crate) fn apatch_command_candidates() -> Vec<ApatchCommandStyle> {
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<ApatchCommandStyle>, style: ApatchCommandStyle) {
if !styles.contains(&style) {
styles.push(style);
}
apatch_command_candidates_for_hint(apatch_kernel_version_hint())
}
fn apatch_kernel_version_hint() -> Option<c_long> {
@ -417,35 +399,6 @@ fn apatch_kernel_version_hint() -> Option<c_long> {
parse_apatch_kernel_version_hint(&String::from_utf8_lossy(&out.stdout))
}
pub(crate) fn parse_apatch_kernel_version_hint(log: &str) -> Option<c_long> {
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(())

View file

@ -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),

View file

@ -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!(

View file

@ -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"

View file

@ -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<i64>) -> Vec<CommandStyle> {
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<i64> {
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));
}
}

View file

@ -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` ·

View file

@ -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.<pid>` 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 |

View file

@ -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/

View file

@ -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,

View file

@ -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=<code> <captured output>`
// 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) {

View file

@ -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")

View file

@ -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" }
}

View file

@ -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()

View file

@ -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<String, String> {
private fun loadRootShellSnapshot(
pmPackagesOverride: String?,
runtimeProbeSource: String?,
): Map<String, String> {
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<String, String>) {
// 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

View file

@ -268,6 +268,7 @@
<string name="dashboard_issue_kpm_awaiting_superkey">KPM установлен, но не активен: APatch/FolkPatch не смог загрузить его через доверенный su-токен. Сохраните SuperKey в Настройки → Безопасность и перезагрузитесь для активации нативного скрытия.</string>
<string name="dashboard_issue_kpm_activator_missing">Модуль KPM повреждён: отсутствует бинарник активатора (%1$s). Переустановите vpnhide-kpm.zip из последнего релиза.</string>
<string name="dashboard_issue_kpm_unsupported_kernel">KPM установлен, но не может загрузиться на ядре %1$s: для этого семейства ядра нет проверенной таблицы смещений. Используйте Zygisk.</string>
<string name="dashboard_issue_kpm_standalone_install">Обнаружена неправильная установка KPM: KernelPatch загрузил отдельный файл vpnhide.kpm, но модуль vpnhide-kpm.zip не установлен. В одном файле .kpm нет активатора VPN Hide, загрузочных скриптов и доставки настроек, поэтому защита не будет работать правильно. Удалите или выгрузите отдельную запись «vpnhide» в APatch/KPatch-Next (если KPM был встроен в ядро, заново пропатчите boot-образ без него), установите целиком vpnhide-kpm.zip через экран «Модули» и перезагрузите устройство.</string>
<string name="dashboard_issue_kpm_load_failed">KPM установлен, но не загрузился: %1$s. Чтобы приложить полный лог загрузки к отчёту об ошибке, нажмите Настройки → Отладка → Собрать отладочный лог, либо переустановите vpnhide-kpm.zip из последнего релиза.</string>
<string name="dashboard_issue_multiple_native">Установлено больше одного Native-бэкенда. Активным может быть только один (приоритет: модуль ядра, затем KPM, затем Zygisk), остальные простаивают. Удалите неиспользуемые, чтобы не засорять систему.</string>
<string name="dashboard_issue_debug_logging_on">Отладочные логи включены. VPN Hide пишет подробные строки в logcat, которые может прочитать любой с root-доступом. Выключите переключатель в Настройки → Отладка после сбора отчёта об ошибке.</string>

View file

@ -298,6 +298,7 @@
<string name="dashboard_issue_kpm_awaiting_superkey">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.</string>
<string name="dashboard_issue_kpm_activator_missing">KPM module is corrupted: the activator binary is missing (%1$s). Reinstall vpnhide-kpm.zip from the latest release.</string>
<string name="dashboard_issue_kpm_unsupported_kernel">KPM is installed but cannot load on kernel %1$s because this kernel family has no validated offset table. Use the Zygisk backend instead.</string>
<string name="dashboard_issue_kpm_standalone_install">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.</string>
<string name="dashboard_issue_kpm_load_failed">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.</string>
<string name="dashboard_issue_multiple_native">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.</string>
<string name="dashboard_issue_debug_logging_on">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.</string>

View file

@ -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"))
}
}

View file

@ -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"])
}
}

View file

@ -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]]

View file

@ -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 }

View file

@ -5,8 +5,28 @@
// `--uid <n>`: the self-in-tunnel gate — report whether uid <n> 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<String> = 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::<u32>().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<String> {
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::<c_void>(),
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<c_long> {
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));
}
}