mirror of
https://github.com/okhsunrog/vpnhide.git
synced 2026-10-19 07:35:03 +00:00
feat(zygisk): add best-effort filesystem path hiding
This commit is contained in:
parent
e712e3b3ec
commit
02015d45e6
29 changed files with 1472 additions and 121 deletions
|
|
@ -0,0 +1,9 @@
|
|||
_2026-08-13_
|
||||
|
||||
## English
|
||||
|
||||
Added optional best-effort filesystem path hiding to the Zygisk backend
|
||||
|
||||
## Русский
|
||||
|
||||
Добавлено опциональное best-effort скрытие путей интерфейсов для бэкенда Zygisk
|
||||
|
|
@ -177,6 +177,10 @@ impl HookSet {
|
|||
Self(self.0 | hook.bit())
|
||||
}
|
||||
|
||||
const fn without(self, hook: Hook) -> Self {
|
||||
Self(self.0 & !hook.bit())
|
||||
}
|
||||
|
||||
const fn restricted_to(self, owner: Self) -> Self {
|
||||
Self(self.0 & owner.0)
|
||||
}
|
||||
|
|
@ -397,11 +401,26 @@ pub(crate) fn project_native_with_resolver_for_family(
|
|||
resolver: &PackageUidMap,
|
||||
family: NativeHookFamily,
|
||||
) -> String {
|
||||
let zygisk_filesystem_enabled = cfg
|
||||
.settings
|
||||
.optional_features
|
||||
.contains(OPTIONAL_FEATURE_FILESYSTEM_IFACE_PATHS);
|
||||
let mut by_uid = BTreeMap::<u32, HookSet>::new();
|
||||
for (pkg, app) in &cfg.apps {
|
||||
let Some(hooks) = app.native.hooks(family) else {
|
||||
let Some(mut hooks) = app.native.hooks(family) else {
|
||||
continue;
|
||||
};
|
||||
// Kernel backends receive the per-app desired bit independently from
|
||||
// whether their load-time hook group is present; their runtime status
|
||||
// reports the installed capability. Zygisk has no separate loader ABI,
|
||||
// so the optional feature gate is projected directly into each process
|
||||
// mask and takes effect when that app next specializes.
|
||||
if family == NativeHookFamily::Zygisk && !zygisk_filesystem_enabled {
|
||||
hooks = hooks.without(Hook::FilesystemIfacePaths);
|
||||
if hooks.is_empty() {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// The APK is intentionally single-owner: only its main-profile copy
|
||||
// may manage the shared config. Keep its mandatory self target just as
|
||||
// singular here. An accidentally installed work/clone-profile copy is
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use vpnhide_apatch_abi::{
|
|||
CommandStyle as ApatchCommandStyle, encode_command as supercall_cmd,
|
||||
parse_kernel_version_hint as parse_apatch_kernel_version_hint,
|
||||
};
|
||||
use vpnhide_protocol::hook_ids::ZYGISK_HOOK_MASK;
|
||||
use vpnhide_protocol::hook_ids::{Hook, ZYGISK_HOOK_MASK};
|
||||
use vpnhide_protocol::{KPM_ARGS_LEN, Target, format_config};
|
||||
|
||||
#[test]
|
||||
|
|
@ -194,6 +194,37 @@ fn kmod_projection_includes_the_optional_filesystem_hook() {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zygisk_projection_gates_filesystem_hook_on_the_optional_feature() {
|
||||
let disabled = parse_canonical(
|
||||
r#"{
|
||||
"version": 1,
|
||||
"apps": { "com.example.full": { "native": true } }
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let enabled = parse_canonical(
|
||||
r#"{
|
||||
"version": 1,
|
||||
"settings": { "optionalFeatures": ["filesystem_iface_paths"] },
|
||||
"apps": { "com.example.full": { "native": true } }
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let resolver = parse_pm_packages("package:com.example.full uid:10123\n");
|
||||
let filesystem_bit = Hook::FilesystemIfacePaths.bit();
|
||||
|
||||
let disabled_wire =
|
||||
project_native_with_resolver_for_family(&disabled, &resolver, NativeHookFamily::Zygisk);
|
||||
let enabled_wire =
|
||||
project_native_with_resolver_for_family(&enabled, &resolver, NativeHookFamily::Zygisk);
|
||||
let disabled_config = vpnhide_protocol::parse_config(disabled_wire.as_bytes()).unwrap();
|
||||
let enabled_config = vpnhide_protocol::parse_config(enabled_wire.as_bytes()).unwrap();
|
||||
|
||||
assert_eq!(disabled_config.targets[0].hookmask & filesystem_bit, 0);
|
||||
assert_ne!(enabled_config.targets[0].hookmask & filesystem_bit, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_projection_ignores_non_kernel_hook_names() {
|
||||
let cfg = parse_canonical(
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ pub enum Hook {
|
|||
SocketBindInterface = 25,
|
||||
/// libc setsockopt() best-effort socket-interface bind denial
|
||||
ZygiskSetsockopt = 26,
|
||||
/// Optional reboot-gated sysfs/proc-sys VPN interface path concealment
|
||||
/// Optional sysfs/proc-sys VPN interface path concealment
|
||||
FilesystemIfacePaths = 27,
|
||||
}
|
||||
|
||||
|
|
@ -146,7 +146,7 @@ pub const HOOK_COUNT: u32 = 28;
|
|||
pub const KERNEL_HOOK_MASK: u32 = 0x20003ff;
|
||||
pub const KMOD_HOOK_MASK: u32 = 0x8000000;
|
||||
pub const KPM_HOOK_MASK: u32 = 0x8000000;
|
||||
pub const ZYGISK_HOOK_MASK: u32 = 0x5fc0000;
|
||||
pub const ZYGISK_HOOK_MASK: u32 = 0xdfc0000;
|
||||
pub const LSPOSED_HOOK_MASK: u32 = 0x3fc00;
|
||||
|
||||
/// status error codes (protocol §5.1).
|
||||
|
|
|
|||
|
|
@ -189,14 +189,15 @@ name = "zygisk_setsockopt"
|
|||
backend = "zygisk"
|
||||
note = "libc setsockopt() best-effort socket-interface bind denial"
|
||||
|
||||
# Optional VFS interception shared by both kernel backends. This is not part of
|
||||
# the required kernel mask because neither backend installs these hot-path hooks
|
||||
# unless the reboot-gated feature was requested before load.
|
||||
# Optional filesystem concealment shared by all native backends. The kernel
|
||||
# implementations use resolved-dentry VFS interception; Zygisk provides a
|
||||
# best-effort libc view. It is not part of the required kernel mask because the
|
||||
# kernel backends do not install their hot-path hooks unless requested at load.
|
||||
[[hook]]
|
||||
id = 27
|
||||
name = "filesystem_iface_paths"
|
||||
backends = ["kmod", "kpm"]
|
||||
note = "Optional reboot-gated sysfs/proc-sys VPN interface path concealment"
|
||||
backends = ["kmod", "kpm", "zygisk"]
|
||||
note = "Optional sysfs/proc-sys VPN interface path concealment"
|
||||
|
||||
# --- backend ids (protocol §4.3 `status backend <id>`) ----------------------
|
||||
# Which backend answered a `status` read. Distinct from the hook `backend`
|
||||
|
|
|
|||
|
|
@ -118,8 +118,8 @@ Legend: ✅ covered · ⚠️ partial / conditional · — not applicable to tha
|
|||
| `ioctl(SIOCGIF{FLAGS,MTU,INDEX,HWADDR,ADDR})` by name | native | ✅ `dev_ioctl` | ✅ `dev_ioctl` | ✅ pre-screen | — | |
|
||||
| netlink `RTM_GETLINK` dump | `recvmsg`/`recvfrom` of `RTM_NEWLINK` | ✅ `rtnl_fill_ifinfo` | ✅ `rtnl_fill_ifinfo` | ✅ filter by index | — | |
|
||||
| netlink `RTM_GETADDR` dump | `RTM_NEWADDR` | ✅ `inet*_fill_ifaddr` | ✅ `inet*_fill_ifaddr` | ✅ filter by index | — | |
|
||||
| `/sys/class/net/<iface>/*` | lookup/stat/open/readlink/readdir reveal iface nodes | ⚠️ optional reboot-gated VFS hooks | ⚠️ optional reboot-gated VFS hooks | — | — | 🔒 usually denied |
|
||||
| `/proc/sys/net/{ipv4,ipv6}/{conf,neigh}/<iface>` | lookup/stat/open/readlink/readdir reveal per-iface sysctl dirs | ⚠️ optional reboot-gated VFS hooks | ⚠️ optional reboot-gated VFS hooks | — | — | 🔒 usually denied |
|
||||
| `/sys/class/net/<iface>/*` | lookup/stat/open/readlink/readdir reveal iface nodes | ⚠️ optional reboot-gated VFS hooks | ⚠️ optional reboot-gated VFS hooks | ⚠️ optional best-effort libc hooks | — | 🔒 usually denied |
|
||||
| `/proc/sys/net/{ipv4,ipv6}/{conf,neigh}/<iface>` | lookup/stat/open/readlink/readdir reveal per-iface sysctl dirs | ⚠️ optional reboot-gated VFS hooks | ⚠️ optional reboot-gated VFS hooks | ⚠️ optional best-effort libc hooks | — | 🔒 usually denied |
|
||||
|
||||
Notes: bionic's `getifaddrs` itself runs over netlink, so the kernel-backend
|
||||
`rtnl_fill_ifinfo` / `inet*_fill_ifaddr` hooks cover the Java and native paths
|
||||
|
|
@ -135,6 +135,15 @@ disabled by default because the redirect points are global hot paths even
|
|||
though filtering remains per target UID. Enable **Hide VPN filesystem paths**
|
||||
in Settings and reboot; when disabled the probes are not registered at all.
|
||||
|
||||
Zygisk projects the same optional feature into hook bit 27 and installs an
|
||||
atomic process-local group for bionic `open*`, `stat`/`lstat`/`fstat`/`fstatat`,
|
||||
`access*`, `readlink*`, and `readdir*` entry points. It recognizes canonical absolute paths
|
||||
and relative `*at` paths resolved through the supplied directory fd, and takes
|
||||
effect when the target app is restarted. This intentionally does **not** claim
|
||||
the kernel contract: raw syscalls (including `getdents64`), unhooked libc
|
||||
aliases, bind mounts, and paths reached through unrelated symlink aliases can
|
||||
bypass it. Use `.ko` or KPM when those vectors matter.
|
||||
|
||||
**`SIOCGIFCONF` size-query subcase — closed in the `.ko`.** The classic two-step
|
||||
`SIOCGIFCONF` (first call with `ifc_req == NULL` to learn the buffer size, then a
|
||||
sized call to fill it) used to leak: the `.ko` filtered the fill but returned the
|
||||
|
|
|
|||
|
|
@ -106,8 +106,9 @@ results. A `null` answer (no root) does not block.
|
|||
- **SELinux can carry part of "protection", and it is invisible without the
|
||||
differential.** `/proc/net/if_inet6` and `/proc/net/dev` still have no kernel
|
||||
backend hook. `/sys/class/net` and the per-interface `/proc/sys/net` trees are
|
||||
covered only when the optional kernel filesystem feature was enabled before
|
||||
reboot. When SELinux denies a path first, the
|
||||
covered by a kernel backend only when the optional filesystem feature was
|
||||
enabled before reboot; Zygisk provides a weaker libc-routed version after the
|
||||
target process restarts. When SELinux denies a path first, the
|
||||
differential attributes that result to SELinux rather than overstating backend
|
||||
coverage. This is also why permissive devices need an explicit warning.
|
||||
- **The VPN lives in protected sockets + per-UID policy tables, not the main route
|
||||
|
|
@ -125,11 +126,11 @@ results. A `null` answer (no root) does not block.
|
|||
used by diagnostics — the root differential already gives the full 4-way + `hidden`
|
||||
without them — and stay only in the Statistics tab.
|
||||
|
||||
## 7. Native check → owning hook (KPM/kmod), verified on Pixel 4a
|
||||
## 7. Native check → owning hook, verified on Pixel 4a
|
||||
|
||||
The kernel backend's 11 always-on hooks plus its optional filesystem hook map
|
||||
to the diagnostic checks below; the "gaps" rows are
|
||||
SELinux/zygisk territory by design, not bugs. Full hiding matrix in
|
||||
The native backend hooks map to the diagnostic checks below. Kernel backends
|
||||
provide the strongest coverage; the optional Zygisk filesystem group covers
|
||||
ordinary libc-routed probes but not raw syscalls or aliases. Full hiding matrix in
|
||||
[detection-vectors.md](detection-vectors.md).
|
||||
|
||||
| check id | probes | kernel hook | notes |
|
||||
|
|
@ -143,8 +144,8 @@ SELinux/zygisk territory by design, not bugs. Full hiding matrix in
|
|||
| `proc_ipv6_route` | `/proc/net/ipv6_route` | `ipv6_route_seq_show` | |
|
||||
| `proc_if_inet6` | `/proc/net/if_inet6` | **(none)** | no kernel seq_show hook — zygisk `openat` or SELinux only |
|
||||
| `proc_dev` | `/proc/net/dev` | **(none)** | zygisk `openat` or SELinux only |
|
||||
| `sys_class_net` | `/sys/class/net` | `filesystem_iface_paths` (`.ko`/KPM, optional) | reboot-gated; otherwise SELinux only |
|
||||
| `proc_sys_net` | `/proc/sys/net/*/{conf,neigh}` | `filesystem_iface_paths` (`.ko`/KPM, optional) | reboot-gated; otherwise SELinux only |
|
||||
| `sys_class_net` | `/sys/class/net` | `filesystem_iface_paths` (`.ko`/KPM/Zygisk, optional) | kernel: resolved-dentry and reboot-gated; Zygisk: best-effort libc and restart-gated |
|
||||
| `proc_sys_net` | `/proc/sys/net/*/{conf,neigh}` | `filesystem_iface_paths` (`.ko`/KPM/Zygisk, optional) | kernel: resolved-dentry and reboot-gated; Zygisk: best-effort libc and restart-gated |
|
||||
|
||||
`socket_bind_interface` intentionally has no app-process root-differential row
|
||||
yet. An honest test needs the hidden interface name/index from the root view,
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ is the key on every control channel including Zygisk; `u64` cumulative counters;
|
|||
supercall (§7).
|
||||
Threat model: an **unprivileged** app — root-level detection is out of scope.
|
||||
|
||||
Configuration storage, activation policy, boot-only features, and loader
|
||||
Configuration storage, activation policy, optional features, and loader
|
||||
arguments are deliberately outside this protocol. Their authoritative documents
|
||||
are [storage.md](storage.md) and [state.md](state.md); backend loader ABIs are in
|
||||
[the `.ko` README](../kmod/README.md) and
|
||||
|
|
@ -365,17 +365,20 @@ is global; a backend only acts on its own bits.
|
|||
Current shared kernel hooks (`.ko` / KPM, 11): `fib_route_seq_show`,
|
||||
`ipv6_route_seq_show`, `rtnl_fill_ifinfo`, `inet_fill_ifaddr`,
|
||||
`inet6_fill_ifaddr`, `dev_ioctl`, `sock_ioctl`, `fib_dump_info`, `rt6_fill_node`,
|
||||
`fib_nl_fill_rule`, `socket_bind_interface`. Both kernel backends also own the
|
||||
optional `filesystem_iface_paths` capability bit. Whether its global hook group
|
||||
is installed is outside control v2; see
|
||||
[storage.md](storage.md#22-optional-kernel-boot-features). Current LSPosed Java hooks (8):
|
||||
`fib_nl_fill_rule`, `socket_bind_interface`. Both kernel backends and Zygisk
|
||||
also own the optional `filesystem_iface_paths` capability bit. For `.ko`/KPM,
|
||||
whether the global hook group is installed is outside control v2. For Zygisk,
|
||||
the activator includes the bit in a target mask only while the same canonical
|
||||
feature is enabled; the process-local libc group reports successful
|
||||
installation through the app heartbeat rather than telemetry v1. See
|
||||
[storage.md](storage.md#22-optional-features). Current LSPosed Java hooks (8):
|
||||
`lsposed_link_properties`,
|
||||
`lsposed_network_capabilities`, `lsposed_network_info`, `lsposed_network`,
|
||||
`lsposed_connectivity_result`, `lsposed_connectivity_callback`,
|
||||
`lsposed_connectivity_network`, `lsposed_package_visibility`. Current Zygisk
|
||||
libc hooks (8): `zygisk_ioctl`, `zygisk_getifaddrs`, `zygisk_openat`,
|
||||
libc hooks (9): `zygisk_ioctl`, `zygisk_getifaddrs`, `zygisk_openat`,
|
||||
`zygisk_recvmsg`, `zygisk_recv`, `zygisk_recvfrom`,
|
||||
`zygisk_recvfrom_chk`, `zygisk_setsockopt`.
|
||||
`zygisk_recvfrom_chk`, `zygisk_setsockopt`, `filesystem_iface_paths`.
|
||||
|
||||
### 5.1 Error codes (`status`)
|
||||
|
||||
|
|
|
|||
|
|
@ -252,7 +252,8 @@ the app when Vector is active.
|
|||
|
||||
`/data/user/0/dev.okhsunrog.vpnhide/files/vpnhide_zygisk_active`
|
||||
|
||||
- Format: `key=value`: `version`, `boot_id`, `pid`, `timestamp`.
|
||||
- Format: `key=value`: `version`, `boot_id`, `pid`, `timestamp`,
|
||||
`requested_hooks`, `installed_hooks`, and optional `filesystem_error`.
|
||||
- Writer: Zygisk module when the current forked target process is the VPN Hide
|
||||
app. This is canonical/native self-targeting for the heartbeat, not LSPosed
|
||||
module scope.
|
||||
|
|
@ -333,7 +334,10 @@ zygote app fork:
|
|||
zygisk on_load
|
||||
-> read module-dir targets.txt text wire
|
||||
zygisk post_app_specialize
|
||||
-> if target: install libc hooks
|
||||
-> if target: install selected libc hooks; filesystem_iface_paths is
|
||||
projected only when the canonical optional feature is enabled
|
||||
-> install the optional filesystem aliases atomically; omit bit 27 from
|
||||
installed_hooks on failure without disabling the base network hooks
|
||||
-> if target process is VPN Hide app: write filesDir/vpnhide_zygisk_active
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -149,9 +149,9 @@ newer versions can extend the set without a schema migration.
|
|||
|
||||
The current projection is:
|
||||
|
||||
| Canonical feature | `.ko` loader ABI | KPM loader ABI |
|
||||
|---|---|---|
|
||||
| `filesystem_iface_paths` | `insmod vpnhide_kmod.ko filesystem_hiding=1` | load `vpnhide.kpm` with the exact argument `filesystem_hiding=1` |
|
||||
| Canonical feature | `.ko` loader ABI | KPM loader ABI | Zygisk projection |
|
||||
|---|---|---|---|
|
||||
| `filesystem_iface_paths` | `insmod vpnhide_kmod.ko filesystem_hiding=1` | load `vpnhide.kpm` with the exact argument `filesystem_hiding=1` | include hook bit 27 in each selected target's Zygisk mask |
|
||||
|
||||
When the feature is absent, the shipped `.ko` loader passes
|
||||
`filesystem_hiding=0`; the KPM loader omits the argument. Either choice is made
|
||||
|
|
@ -159,8 +159,11 @@ before hook installation, so changing the canonical set takes effect only after
|
|||
a reboot. Backend-specific parsing and failure behavior are documented in the
|
||||
[`.ko` README](../kmod/README.md#optional-filesystem-hook-loader-contract) and
|
||||
[KPM README](../kmod/kpm/README.md#optional-filesystem-hook-loader-contract).
|
||||
For Zygisk, the activator instead projects the feature into each target's
|
||||
module-dir control snapshot. A force-stop and restart of the target process is
|
||||
enough to pick up the change; no reboot or loader argument is involved.
|
||||
|
||||
Filesystem hiding then has two independent gates:
|
||||
Filesystem hiding has two independent gates on kernel backends:
|
||||
|
||||
1. The boot feature installs the four global VFS interception points. With the
|
||||
feature off, the backend reports no `filesystem_iface_paths` capability and
|
||||
|
|
@ -169,6 +172,13 @@ Filesystem hiding then has two independent gates:
|
|||
capability. Installing the hooks alone does not hide anything for a UID whose
|
||||
target mask omits the bit.
|
||||
|
||||
Zygisk uses the same canonical feature and hook ID, but its first gate is an
|
||||
atomic group of process-local libc hooks installed during app specialization.
|
||||
The heartbeat records both the requested and successfully installed masks; if
|
||||
any filesystem symbol cannot be hooked, bit 27 is omitted from the installed
|
||||
mask while the ordinary Zygisk network hooks remain active. This is deliberately
|
||||
best-effort coverage rather than the kernel backends' resolved-dentry contract.
|
||||
|
||||
The symbolic feature name and loader arguments belong to storage/activation;
|
||||
hook ID 27, its mask semantics, and its installed/status bit belong to the
|
||||
[wire protocol](protocol.md#5-hook-registry-global-id-space).
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ equivalent optional KPM hooks.
|
|||
|
||||
`filesystem_hiding` is a read-only boolean module parameter consumed only by
|
||||
`insmod`; it is not a control-v2 field. The shipped activator's `boot-load`
|
||||
command reads canonical boot feature `filesystem_iface_paths` and passes
|
||||
command reads canonical optional feature `filesystem_iface_paths` and passes
|
||||
`filesystem_hiding=1` or `filesystem_hiding=0` accordingly. Omitting the
|
||||
parameter has the same disabled behavior as `0`.
|
||||
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ static inline unsigned int vpnhide_hook_bit(enum vpnhide_hook_id id)
|
|||
#define VPNHIDE_KERNEL_HOOK_MASK 0x20003ffu
|
||||
#define VPNHIDE_KMOD_HOOK_MASK 0x8000000u
|
||||
#define VPNHIDE_KPM_HOOK_MASK 0x8000000u
|
||||
#define VPNHIDE_ZYGISK_HOOK_MASK 0x5fc0000u
|
||||
#define VPNHIDE_ZYGISK_HOOK_MASK 0xdfc0000u
|
||||
#define VPNHIDE_LSPOSED_HOOK_MASK 0x3fc00u
|
||||
|
||||
/* Dense stats slots shared by the .ko and KPM kernel hooks.
|
||||
|
|
|
|||
|
|
@ -507,7 +507,7 @@ private fun AppRow(
|
|||
selectedHooks = nativeHooks,
|
||||
roleEnabled = app.native,
|
||||
notice =
|
||||
if (nativeHookFamily != NativeHookFamily.Zygisk && !filesystemHidingEnabled) {
|
||||
if (!filesystemHidingEnabled) {
|
||||
stringResource(R.string.native_hooks_filesystem_hiding_disabled)
|
||||
} else {
|
||||
null
|
||||
|
|
|
|||
|
|
@ -1641,11 +1641,7 @@ internal suspend fun loadDashboardState(
|
|||
// computation, surfaced via the hero warning so a leak there is never invisible.
|
||||
var unownedNativeLeakCount = 0
|
||||
val installedOptionalHooks =
|
||||
when (nativeBackend.id) {
|
||||
NativeBackendId.Kmod -> installedHooks(shellSnapshot["kmod_state"].orEmpty())
|
||||
NativeBackendId.Kpm -> installedHooks(shellSnapshot["kpm_state"].orEmpty())
|
||||
else -> emptySet()
|
||||
}
|
||||
installedNativeOptionalHooks(nativeBackend.id, shellSnapshot, currentBootId)
|
||||
// Single source of truth: the cache does all the gating (VPN off / needs-restart /
|
||||
// self-not-routed) through the one fold. awaitTerminal returns the terminal state
|
||||
// itself, so the reason for "no results" (blocked gate vs a failed run) is carried
|
||||
|
|
|
|||
|
|
@ -14,41 +14,65 @@ internal enum class FilesystemHidingStatus {
|
|||
|
||||
internal data class FilesystemHidingState(
|
||||
val status: FilesystemHidingStatus,
|
||||
val backend: NativeBackendId? = null,
|
||||
val errorDetail: String? = null,
|
||||
) {
|
||||
val kernelBackendInstalled: Boolean
|
||||
val nativeBackendInstalled: Boolean
|
||||
get() = status != FilesystemHidingStatus.Unavailable
|
||||
}
|
||||
|
||||
private data class FilesystemKernelBackend(
|
||||
private data class FilesystemNativeBackend(
|
||||
val id: NativeBackendId,
|
||||
val statusRaw: String,
|
||||
)
|
||||
|
||||
private fun filesystemKernelBackend(sections: Map<String, String>): FilesystemKernelBackend? {
|
||||
private fun filesystemNativeBackend(sections: Map<String, String>): FilesystemNativeBackend? {
|
||||
val kmodRaw = sections["kmod_state"].orEmpty()
|
||||
val kpmRaw = sections["kpm_state"].orEmpty()
|
||||
val zygiskRaw = sections["zygisk_status"].orEmpty()
|
||||
val kmodStatus = parseProtocolStatusBlock(kmodRaw)
|
||||
val kpmStatus = parseProtocolStatusBlock(kpmRaw)
|
||||
val zygiskActive =
|
||||
parseKeyValueLines(zygiskRaw)["boot_id"] ==
|
||||
sections["current_boot_id"].orEmpty().trim() &&
|
||||
sections["current_boot_id"].orEmpty().isNotBlank()
|
||||
return when {
|
||||
kmodStatus?.backend ==
|
||||
HookIds.Backend.KMOD.id
|
||||
.toLong() -> FilesystemKernelBackend(NativeBackendId.Kmod, kmodRaw)
|
||||
.toLong() -> {
|
||||
FilesystemNativeBackend(NativeBackendId.Kmod, kmodRaw)
|
||||
}
|
||||
|
||||
kpmStatus?.backend ==
|
||||
HookIds.Backend.KPM.id
|
||||
.toLong() -> FilesystemKernelBackend(NativeBackendId.Kpm, kpmRaw)
|
||||
.toLong() -> {
|
||||
FilesystemNativeBackend(NativeBackendId.Kpm, kpmRaw)
|
||||
}
|
||||
|
||||
sections["kmod_module_dir"]?.trim() == "1" -> FilesystemKernelBackend(NativeBackendId.Kmod, kmodRaw)
|
||||
zygiskActive -> {
|
||||
FilesystemNativeBackend(NativeBackendId.Zygisk, zygiskRaw)
|
||||
}
|
||||
|
||||
sections["kpm_module_dir"]?.trim() == "1" -> FilesystemKernelBackend(NativeBackendId.Kpm, kpmRaw)
|
||||
sections["kmod_module_dir"]?.trim() == "1" -> {
|
||||
FilesystemNativeBackend(NativeBackendId.Kmod, kmodRaw)
|
||||
}
|
||||
|
||||
else -> null
|
||||
sections["kpm_module_dir"]?.trim() == "1" -> {
|
||||
FilesystemNativeBackend(NativeBackendId.Kpm, kpmRaw)
|
||||
}
|
||||
|
||||
sections["zygisk_module_dir"]?.trim() == "1" -> {
|
||||
FilesystemNativeBackend(NativeBackendId.Zygisk, zygiskRaw)
|
||||
}
|
||||
|
||||
else -> {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun currentKmodLoadStatus(
|
||||
backend: FilesystemKernelBackend,
|
||||
backend: FilesystemNativeBackend,
|
||||
sections: Map<String, String>,
|
||||
): KmodLoadStatus? =
|
||||
if (backend.id == NativeBackendId.Kmod) {
|
||||
|
|
@ -62,7 +86,7 @@ private fun currentKmodLoadStatus(
|
|||
}
|
||||
|
||||
private fun filesystemHookSetupFailed(
|
||||
backend: FilesystemKernelBackend,
|
||||
backend: FilesystemNativeBackend,
|
||||
load: KmodLoadStatus?,
|
||||
sections: Map<String, String>,
|
||||
): Boolean =
|
||||
|
|
@ -80,53 +104,131 @@ private fun filesystemHookSetupFailed(
|
|||
}
|
||||
|
||||
NativeBackendId.Zygisk -> {
|
||||
false
|
||||
val status = currentZygiskHookStatus(backend.statusRaw, sections["current_boot_id"].orEmpty())
|
||||
status != null &&
|
||||
status.requested.hasHook(HookIds.Hook.FILESYSTEM_IFACE_PATHS) &&
|
||||
!status.installed.hasHook(HookIds.Hook.FILESYSTEM_IFACE_PATHS) &&
|
||||
status.filesystemError.isNotBlank()
|
||||
}
|
||||
}
|
||||
|
||||
private data class ZygiskHookStatus(
|
||||
val requested: Long,
|
||||
val installed: Long,
|
||||
val filesystemError: String,
|
||||
)
|
||||
|
||||
private fun currentZygiskHookStatus(
|
||||
raw: String,
|
||||
currentBootId: String,
|
||||
): ZygiskHookStatus? {
|
||||
val values = parseKeyValueLines(raw)
|
||||
if (values["boot_id"] != currentBootId.trim() || currentBootId.isBlank()) return null
|
||||
return ZygiskHookStatus(
|
||||
requested = values["requested_hooks"]?.toLongOrNull(16) ?: 0,
|
||||
installed = values["installed_hooks"]?.toLongOrNull(16) ?: 0,
|
||||
filesystemError = values["filesystem_error"].orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
internal fun installedZygiskOptionalHooks(
|
||||
raw: String,
|
||||
currentBootId: String,
|
||||
): Set<HookIds.Hook> =
|
||||
currentZygiskHookStatus(raw, currentBootId)
|
||||
?.installed
|
||||
?.let(::hooksInMask)
|
||||
?.intersect(setOf(HookIds.Hook.FILESYSTEM_IFACE_PATHS))
|
||||
.orEmpty()
|
||||
|
||||
internal fun installedNativeOptionalHooks(
|
||||
backend: NativeBackendId?,
|
||||
sections: Map<String, String>,
|
||||
currentBootId: String,
|
||||
): Set<HookIds.Hook> =
|
||||
when (backend) {
|
||||
NativeBackendId.Kmod -> {
|
||||
installedHooks(sections["kmod_state"].orEmpty())
|
||||
}
|
||||
|
||||
NativeBackendId.Kpm -> {
|
||||
installedHooks(sections["kpm_state"].orEmpty())
|
||||
}
|
||||
|
||||
NativeBackendId.Zygisk -> {
|
||||
installedZygiskOptionalHooks(
|
||||
sections["zygisk_status"].orEmpty(),
|
||||
currentBootId,
|
||||
)
|
||||
}
|
||||
|
||||
null -> {
|
||||
emptySet()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare the canonical next-boot choice with the hook set actually reported
|
||||
* by the running kernel backend. Boot diagnostics distinguish a newly changed
|
||||
* setting from a hook that was requested this boot but failed to install.
|
||||
* Compare the canonical choice with the hook set actually reported by the
|
||||
* active native backend. Kernel backends apply it at boot; Zygisk applies it
|
||||
* per target-process launch and reports the installed subset in its heartbeat.
|
||||
*/
|
||||
internal fun resolveFilesystemHidingState(
|
||||
desiredEnabled: Boolean,
|
||||
sections: Map<String, String>,
|
||||
): FilesystemHidingState {
|
||||
val backend =
|
||||
filesystemKernelBackend(sections)
|
||||
filesystemNativeBackend(sections)
|
||||
?: return FilesystemHidingState(FilesystemHidingStatus.Unavailable)
|
||||
val hookInstalled =
|
||||
HookIds.Hook.FILESYSTEM_IFACE_PATHS in
|
||||
installedHooks(backend.statusRaw)
|
||||
when (backend.id) {
|
||||
NativeBackendId.Kmod, NativeBackendId.Kpm -> {
|
||||
HookIds.Hook.FILESYSTEM_IFACE_PATHS in installedHooks(backend.statusRaw)
|
||||
}
|
||||
|
||||
NativeBackendId.Zygisk -> {
|
||||
HookIds.Hook.FILESYSTEM_IFACE_PATHS in
|
||||
installedZygiskOptionalHooks(
|
||||
backend.statusRaw,
|
||||
sections["current_boot_id"].orEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
val load = currentKmodLoadStatus(backend, sections)
|
||||
val configExit = load?.filesystemConfigExit
|
||||
if (configExit != null && configExit != 0 && configExit != 1) {
|
||||
return FilesystemHidingState(
|
||||
status = FilesystemHidingStatus.BootConfigError,
|
||||
backend = backend.id,
|
||||
errorDetail = load.filesystemConfigError ?: "exit=$configExit",
|
||||
)
|
||||
}
|
||||
|
||||
return when {
|
||||
desiredEnabled && hookInstalled -> {
|
||||
FilesystemHidingState(FilesystemHidingStatus.Active)
|
||||
FilesystemHidingState(FilesystemHidingStatus.Active, backend.id)
|
||||
}
|
||||
|
||||
!desiredEnabled && hookInstalled -> {
|
||||
FilesystemHidingState(FilesystemHidingStatus.PendingDisable)
|
||||
FilesystemHidingState(FilesystemHidingStatus.PendingDisable, backend.id)
|
||||
}
|
||||
|
||||
desiredEnabled && filesystemHookSetupFailed(backend, load, sections) -> {
|
||||
FilesystemHidingState(FilesystemHidingStatus.HookSetupError)
|
||||
FilesystemHidingState(
|
||||
FilesystemHidingStatus.HookSetupError,
|
||||
backend.id,
|
||||
currentZygiskHookStatus(
|
||||
backend.statusRaw,
|
||||
sections["current_boot_id"].orEmpty(),
|
||||
)?.filesystemError,
|
||||
)
|
||||
}
|
||||
|
||||
desiredEnabled -> {
|
||||
FilesystemHidingState(FilesystemHidingStatus.PendingEnable)
|
||||
FilesystemHidingState(FilesystemHidingStatus.PendingEnable, backend.id)
|
||||
}
|
||||
|
||||
else -> {
|
||||
FilesystemHidingState(FilesystemHidingStatus.Disabled)
|
||||
FilesystemHidingState(FilesystemHidingStatus.Disabled, backend.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,14 +12,26 @@ internal fun filesystemHidingDashboardMessage(
|
|||
FilesystemHidingStatus.PendingEnable -> {
|
||||
DashboardMessage(
|
||||
DashboardMessageSeverity.WARNING,
|
||||
res.getString(R.string.dashboard_issue_filesystem_hiding_pending_enable),
|
||||
res.getString(
|
||||
if (state.backend == NativeBackendId.Zygisk) {
|
||||
R.string.dashboard_issue_filesystem_hiding_pending_enable_zygisk
|
||||
} else {
|
||||
R.string.dashboard_issue_filesystem_hiding_pending_enable
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
FilesystemHidingStatus.PendingDisable -> {
|
||||
DashboardMessage(
|
||||
DashboardMessageSeverity.WARNING,
|
||||
res.getString(R.string.dashboard_issue_filesystem_hiding_pending_disable),
|
||||
res.getString(
|
||||
if (state.backend == NativeBackendId.Zygisk) {
|
||||
R.string.dashboard_issue_filesystem_hiding_pending_disable_zygisk
|
||||
} else {
|
||||
R.string.dashboard_issue_filesystem_hiding_pending_disable
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -50,7 +50,14 @@ internal fun FilesystemHidingSettingsSection() {
|
|||
sections = rootSnapshot?.sections.orEmpty(),
|
||||
)
|
||||
}
|
||||
val savedMessage = stringResource(R.string.settings_filesystem_hiding_saved)
|
||||
val savedMessage =
|
||||
stringResource(
|
||||
if (runtimeState.backend == NativeBackendId.Zygisk) {
|
||||
R.string.settings_filesystem_hiding_saved_zygisk
|
||||
} else {
|
||||
R.string.settings_filesystem_hiding_saved
|
||||
},
|
||||
)
|
||||
val failedMessage = stringResource(R.string.settings_filesystem_hiding_failed)
|
||||
|
||||
fun persist(value: Boolean) {
|
||||
|
|
@ -70,7 +77,17 @@ internal fun FilesystemHidingSettingsSection() {
|
|||
AlertDialog(
|
||||
onDismissRequest = { if (!saving) confirmationOpen = false },
|
||||
title = { Text(stringResource(R.string.settings_filesystem_hiding_confirm_title)) },
|
||||
text = { Text(stringResource(R.string.settings_filesystem_hiding_confirm_body)) },
|
||||
text = {
|
||||
Text(
|
||||
stringResource(
|
||||
if (runtimeState.backend == NativeBackendId.Zygisk) {
|
||||
R.string.settings_filesystem_hiding_confirm_body_zygisk
|
||||
} else {
|
||||
R.string.settings_filesystem_hiding_confirm_body
|
||||
},
|
||||
),
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
enabled = !saving,
|
||||
|
|
@ -104,7 +121,7 @@ internal fun FilesystemHidingSettingsSection() {
|
|||
subtitle = filesystemHidingStatusText(runtimeState),
|
||||
icon = Icons.Default.VisibilityOff,
|
||||
checked = enabled,
|
||||
enabled = targets != null && !saving && (runtimeState.kernelBackendInstalled || enabled),
|
||||
enabled = targets != null && !saving && (runtimeState.nativeBackendInstalled || enabled),
|
||||
onCheckedChange = { value ->
|
||||
if (value) confirmationOpen = true else persist(false)
|
||||
},
|
||||
|
|
@ -141,11 +158,23 @@ private fun filesystemHidingStatusText(state: FilesystemHidingState): String =
|
|||
}
|
||||
|
||||
FilesystemHidingStatus.PendingEnable -> {
|
||||
stringResource(R.string.settings_filesystem_hiding_pending_enable)
|
||||
stringResource(
|
||||
if (state.backend == NativeBackendId.Zygisk) {
|
||||
R.string.settings_filesystem_hiding_pending_enable_zygisk
|
||||
} else {
|
||||
R.string.settings_filesystem_hiding_pending_enable
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
FilesystemHidingStatus.PendingDisable -> {
|
||||
stringResource(R.string.settings_filesystem_hiding_pending_disable)
|
||||
stringResource(
|
||||
if (state.backend == NativeBackendId.Zygisk) {
|
||||
R.string.settings_filesystem_hiding_pending_disable_zygisk
|
||||
} else {
|
||||
R.string.settings_filesystem_hiding_pending_disable
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
FilesystemHidingStatus.BootConfigError -> {
|
||||
|
|
|
|||
|
|
@ -62,7 +62,8 @@ internal fun ownedNativeHooks(
|
|||
}
|
||||
|
||||
NativeBackendId.Zygisk -> {
|
||||
ZYGISK_HOOKS
|
||||
ZYGISK_HOOKS - HookIds.Hook.FILESYSTEM_IFACE_PATHS +
|
||||
installedOptionalHooks.intersect(setOf(HookIds.Hook.FILESYSTEM_IFACE_PATHS))
|
||||
}
|
||||
|
||||
null -> {
|
||||
|
|
|
|||
|
|
@ -90,15 +90,15 @@ internal object HookIds {
|
|||
// libc setsockopt() best-effort socket-interface bind denial
|
||||
ZYGISK_SETSOCKOPT(26, "zygisk_setsockopt", "libc setsockopt() best-effort socket-interface bind denial"),
|
||||
|
||||
// Optional reboot-gated sysfs/proc-sys VPN interface path concealment
|
||||
FILESYSTEM_IFACE_PATHS(27, "filesystem_iface_paths", "Optional reboot-gated sysfs/proc-sys VPN interface path concealment"),
|
||||
// Optional sysfs/proc-sys VPN interface path concealment
|
||||
FILESYSTEM_IFACE_PATHS(27, "filesystem_iface_paths", "Optional sysfs/proc-sys VPN interface path concealment"),
|
||||
}
|
||||
|
||||
// Hooks owned by each backend: apply `mask and own`.
|
||||
const val KERNEL_HOOK_MASK = 0x20003ff
|
||||
const val KMOD_HOOK_MASK = 0x8000000
|
||||
const val KPM_HOOK_MASK = 0x8000000
|
||||
const val ZYGISK_HOOK_MASK = 0x5fc0000
|
||||
const val ZYGISK_HOOK_MASK = 0xdfc0000
|
||||
const val LSPOSED_HOOK_MASK = 0x3fc00
|
||||
|
||||
/** status error codes (protocol §5.1). */
|
||||
|
|
|
|||
|
|
@ -413,24 +413,30 @@
|
|||
<string name="settings_diagnostics_section">Диагностика</string>
|
||||
<string name="settings_experimental_protection">Экспериментальная защита</string>
|
||||
<string name="settings_filesystem_hiding">Скрывать VPN-пути в файловой системе</string>
|
||||
<string name="settings_filesystem_hiding_sub">Экспериментальный и потенциально дорогой хук: он перехватывает глобальные операции поиска и перечисления в файловой системе и может замедлить устройство. Сейчас неизвестны реальные приложения, использующие этот вектор обнаружения; обычной Native-защиты, как правило, достаточно. Оставьте функцию выключенной, если вы специально не тестируете обнаружение по путям файловой системы.</string>
|
||||
<string name="settings_filesystem_hiding_unavailable">Требуется ядерный бэкенд kmod или KPM.</string>
|
||||
<string name="settings_filesystem_hiding_sub">Экспериментальная функция: ядерные бэкенды перехватывают глобальные VFS-пути, а Zygisk выполняет best-effort фильтрацию libc внутри выбранных приложений. Сейчас неизвестны реальные приложения, использующие этот вектор; обычной Native-защиты, как правило, достаточно.</string>
|
||||
<string name="settings_filesystem_hiding_unavailable">Требуется Native-бэкенд kmod, KPM или Zygisk.</string>
|
||||
<string name="settings_filesystem_hiding_disabled">Выключено — рекомендуется обычная Native-защита.</string>
|
||||
<string name="settings_filesystem_hiding_active">Активно для выбранных Native-целей.</string>
|
||||
<string name="settings_filesystem_hiding_pending_enable">Включится после перезагрузки.</string>
|
||||
<string name="settings_filesystem_hiding_pending_disable">Выключится после перезагрузки.</string>
|
||||
<string name="settings_filesystem_hiding_pending_enable_zygisk">Перезапустите выбранные приложения для включения.</string>
|
||||
<string name="settings_filesystem_hiding_pending_disable_zygisk">Перезапустите выбранные приложения для выключения.</string>
|
||||
<string name="settings_filesystem_hiding_boot_error">Не удалось прочитать настройку при загрузке: %1$s</string>
|
||||
<string name="settings_filesystem_hiding_setup_error">Ядерный бэкенд загружен, но хуки файловой системы не установились. Проверьте диагностику перед следующей перезагрузкой.</string>
|
||||
<string name="settings_filesystem_hiding_setup_error">Активный бэкенд не смог установить хуки файловой системы. Подробности доступны в диагностике.</string>
|
||||
<string name="settings_filesystem_hiding_confirm_title">Включить экспериментальные хуки файловой системы?</string>
|
||||
<string name="settings_filesystem_hiding_confirm_body">Эти глобальные VFS-хуки могут замедлять операции с файловой системой и сейчас не нужны известным реальным приложениям. Обычной Native-защиты, как правило, достаточно. Включайте их только для целевого тестирования обнаружения по путям файловой системы; потребуется перезагрузка.</string>
|
||||
<string name="settings_filesystem_hiding_confirm_body_zygisk">Zygisk добавит best-effort libc-хуки файловой системы внутри выбранных приложений. Raw syscalls и альтернативные пути могут их обойти. Включайте функцию только для целевого тестирования; выбранные приложения потребуется перезапустить.</string>
|
||||
<string name="settings_filesystem_hiding_enable">Всё равно включить</string>
|
||||
<string name="settings_filesystem_hiding_saved">Сохранено. Перезагрузите устройство, чтобы применить изменение.</string>
|
||||
<string name="settings_filesystem_hiding_saved_zygisk">Сохранено. Перезапустите выбранные приложения, чтобы применить изменение.</string>
|
||||
<string name="settings_filesystem_hiding_failed">Не удалось сохранить настройку скрытия путей.</string>
|
||||
<string name="native_hooks_filesystem_hiding_disabled">Хук путей файловой системы не включён в настройках. Включите его в разделе «Настройки → Экспериментальная защита» и перезагрузите устройство, прежде чем рассчитывать на него.</string>
|
||||
<string name="native_hooks_filesystem_hiding_disabled">Хук путей файловой системы не включён в настройках. Включите его в разделе «Настройки → Экспериментальная защита», затем перезапустите целевые приложения для Zygisk или устройство для kmod/KPM.</string>
|
||||
<string name="dashboard_issue_filesystem_hiding_pending_enable">Экспериментальное скрытие путей включено в настройках и ожидает перезагрузки.</string>
|
||||
<string name="dashboard_issue_filesystem_hiding_pending_disable">Экспериментальное скрытие путей останется активным до следующей перезагрузки.</string>
|
||||
<string name="dashboard_issue_filesystem_hiding_pending_enable_zygisk">Скрытие путей включено; перезапустите выбранные приложения для установки Zygisk-хуков.</string>
|
||||
<string name="dashboard_issue_filesystem_hiding_pending_disable_zygisk">Скрытие путей останется активным в запущенных целевых приложениях до их перезапуска.</string>
|
||||
<string name="dashboard_issue_filesystem_hiding_boot_error">Скрытие путей не смогло прочитать настройку при загрузке: %1$s</string>
|
||||
<string name="dashboard_issue_filesystem_hiding_setup_error">Экспериментальное скрытие путей было запрошено, но его kmod-хуки не установились при этой загрузке.</string>
|
||||
<string name="dashboard_issue_filesystem_hiding_setup_error">Экспериментальное скрытие путей было запрошено, но активный бэкенд не смог установить хуки.</string>
|
||||
<string name="settings_diagnostics_title">Подробная диагностика</string>
|
||||
<string name="settings_diagnostics_sub">Полный набор проверок скрытия</string>
|
||||
<string name="settings_debug_section">Отладка</string>
|
||||
|
|
|
|||
|
|
@ -442,24 +442,30 @@
|
|||
<string name="settings_diagnostics_section">Diagnostics</string>
|
||||
<string name="settings_experimental_protection">Experimental protection</string>
|
||||
<string name="settings_filesystem_hiding">Hide VPN filesystem paths</string>
|
||||
<string name="settings_filesystem_hiding_sub">Experimental and potentially expensive: this hook intercepts global filesystem lookup and directory-enumeration paths and may slow the device. No known real-world apps currently rely on this detection vector; standard Native protection should normally be sufficient. Leave it off unless you specifically need to test filesystem-path detection.</string>
|
||||
<string name="settings_filesystem_hiding_unavailable">Requires the kmod or KPM kernel backend.</string>
|
||||
<string name="settings_filesystem_hiding_sub">Experimental: kernel backends intercept global VFS paths, while Zygisk provides best-effort libc filtering inside selected apps. No known real-world apps currently rely on this detection vector; standard Native protection should normally be sufficient.</string>
|
||||
<string name="settings_filesystem_hiding_unavailable">Requires a kmod, KPM, or Zygisk native backend.</string>
|
||||
<string name="settings_filesystem_hiding_disabled">Off — standard Native protection is recommended.</string>
|
||||
<string name="settings_filesystem_hiding_active">Active for selected Native targets.</string>
|
||||
<string name="settings_filesystem_hiding_pending_enable">Will be enabled after reboot.</string>
|
||||
<string name="settings_filesystem_hiding_pending_disable">Will be disabled after reboot.</string>
|
||||
<string name="settings_filesystem_hiding_pending_enable_zygisk">Restart selected apps to enable it.</string>
|
||||
<string name="settings_filesystem_hiding_pending_disable_zygisk">Restart selected apps to disable it.</string>
|
||||
<string name="settings_filesystem_hiding_boot_error">Could not read the boot setting: %1$s</string>
|
||||
<string name="settings_filesystem_hiding_setup_error">The kernel backend loaded, but the filesystem hooks failed to install. Check Diagnostics before rebooting again.</string>
|
||||
<string name="settings_filesystem_hiding_setup_error">The active backend could not install its filesystem hooks. Check Diagnostics for details.</string>
|
||||
<string name="settings_filesystem_hiding_confirm_title">Enable experimental filesystem hooks?</string>
|
||||
<string name="settings_filesystem_hiding_confirm_body">These global VFS hooks may slow filesystem operations and are not currently needed for known real-world apps. Ordinary Native protection should normally be sufficient. Enable them only for explicit filesystem-path detection testing; a reboot is required.</string>
|
||||
<string name="settings_filesystem_hiding_confirm_body_zygisk">Zygisk will add best-effort libc filesystem hooks inside selected apps. Raw syscalls and path aliases can bypass them. Enable this only for explicit filesystem-path detection testing; selected apps must be restarted.</string>
|
||||
<string name="settings_filesystem_hiding_enable">Enable anyway</string>
|
||||
<string name="settings_filesystem_hiding_saved">Saved. Reboot to apply the change.</string>
|
||||
<string name="settings_filesystem_hiding_saved_zygisk">Saved. Restart selected apps to apply the change.</string>
|
||||
<string name="settings_filesystem_hiding_failed">Failed to save the filesystem hiding setting.</string>
|
||||
<string name="native_hooks_filesystem_hiding_disabled">The filesystem-path hook is not enabled in Settings. Enable it under Settings → Experimental protection and reboot before relying on it.</string>
|
||||
<string name="native_hooks_filesystem_hiding_disabled">The filesystem-path hook is not enabled in Settings. Enable it under Settings → Experimental protection, then restart target apps for Zygisk or reboot for kmod/KPM before relying on it.</string>
|
||||
<string name="dashboard_issue_filesystem_hiding_pending_enable">Experimental filesystem-path hiding is enabled in Settings but is waiting for a reboot.</string>
|
||||
<string name="dashboard_issue_filesystem_hiding_pending_disable">Experimental filesystem-path hiding is still active until the next reboot.</string>
|
||||
<string name="dashboard_issue_filesystem_hiding_pending_enable_zygisk">Filesystem-path hiding is enabled; restart selected apps to install the Zygisk hooks.</string>
|
||||
<string name="dashboard_issue_filesystem_hiding_pending_disable_zygisk">Filesystem-path hiding remains active in running target apps until they restart.</string>
|
||||
<string name="dashboard_issue_filesystem_hiding_boot_error">Filesystem-path hiding could not read its boot setting: %1$s</string>
|
||||
<string name="dashboard_issue_filesystem_hiding_setup_error">Experimental filesystem-path hiding was requested, but its kmod hooks failed to install this boot.</string>
|
||||
<string name="dashboard_issue_filesystem_hiding_setup_error">Experimental filesystem-path hiding was requested, but the active backend failed to install its hooks.</string>
|
||||
<string name="settings_diagnostics_title">Detailed diagnostics</string>
|
||||
<string name="settings_diagnostics_sub">Run the full hiding check suite</string>
|
||||
<string name="settings_debug_section">Debugging</string>
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ class FilesystemHidingDataTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `feature is unavailable without a kernel backend`() {
|
||||
fun `feature is unavailable without a native backend`() {
|
||||
assertEquals(
|
||||
FilesystemHidingStatus.Unavailable,
|
||||
resolveFilesystemHidingState(desiredEnabled = false, sections = emptyMap()).status,
|
||||
|
|
@ -69,6 +69,71 @@ class FilesystemHidingDataTest {
|
|||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Zygisk heartbeat reports active optional filesystem hooks`() {
|
||||
val sections =
|
||||
zygiskSections(
|
||||
requested = HookIds.Hook.FILESYSTEM_IFACE_PATHS.bit,
|
||||
installed = HookIds.Hook.FILESYSTEM_IFACE_PATHS.bit,
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
FilesystemHidingStatus.Active,
|
||||
resolveFilesystemHidingState(desiredEnabled = true, sections = sections).status,
|
||||
)
|
||||
assertEquals(
|
||||
setOf(HookIds.Hook.FILESYSTEM_IFACE_PATHS),
|
||||
installedZygiskOptionalHooks(sections.getValue("zygisk_status"), "test-boot"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `active Zygisk heartbeat wins over an installed inactive kmod`() {
|
||||
val sections =
|
||||
zygiskSections(
|
||||
requested = HookIds.Hook.FILESYSTEM_IFACE_PATHS.bit,
|
||||
installed = HookIds.Hook.FILESYSTEM_IFACE_PATHS.bit,
|
||||
) + ("kmod_module_dir" to "1")
|
||||
|
||||
val state = resolveFilesystemHidingState(desiredEnabled = true, sections = sections)
|
||||
assertEquals(FilesystemHidingStatus.Active, state.status)
|
||||
assertEquals(NativeBackendId.Zygisk, state.backend)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Zygisk optional hook failure is reported without claiming ownership`() {
|
||||
val sections =
|
||||
zygiskSections(
|
||||
requested = HookIds.Hook.FILESYSTEM_IFACE_PATHS.bit,
|
||||
installed = 0,
|
||||
error = "shadowhook failed",
|
||||
)
|
||||
val state = resolveFilesystemHidingState(desiredEnabled = true, sections = sections)
|
||||
|
||||
assertEquals(FilesystemHidingStatus.HookSetupError, state.status)
|
||||
assertEquals(NativeBackendId.Zygisk, state.backend)
|
||||
assertEquals("shadowhook failed", state.errorDetail)
|
||||
assertEquals(
|
||||
emptySet<HookIds.Hook>(),
|
||||
installedZygiskOptionalHooks(sections.getValue("zygisk_status"), "test-boot"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stale Zygisk heartbeat leaves an enabled feature pending`() {
|
||||
val sections =
|
||||
zygiskSections(
|
||||
requested = HookIds.Hook.FILESYSTEM_IFACE_PATHS.bit,
|
||||
installed = HookIds.Hook.FILESYSTEM_IFACE_PATHS.bit,
|
||||
bootId = "old-boot",
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
FilesystemHidingStatus.PendingEnable,
|
||||
resolveFilesystemHidingState(desiredEnabled = true, sections = sections).status,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desired and runtime states distinguish both reboot directions`() {
|
||||
assertEquals(
|
||||
|
|
@ -170,4 +235,22 @@ class FilesystemHidingDataTest {
|
|||
loadStatusKey to loadStatus,
|
||||
)
|
||||
}
|
||||
|
||||
private fun zygiskSections(
|
||||
requested: Long,
|
||||
installed: Long,
|
||||
error: String = "",
|
||||
bootId: String = "test-boot",
|
||||
): Map<String, String> =
|
||||
mapOf(
|
||||
"zygisk_module_dir" to "1",
|
||||
"current_boot_id" to "test-boot",
|
||||
"zygisk_status" to
|
||||
"""
|
||||
boot_id=$bootId
|
||||
requested_hooks=${requested.toString(16)}
|
||||
installed_hooks=${installed.toString(16)}
|
||||
filesystem_error=$error
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,6 +107,25 @@ class LayerStatusTest {
|
|||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `optional Zygisk filesystem vectors are owned only after the group installs`() {
|
||||
val outcomes = mapOf("sys_class_net" to CheckOutcome.Leak)
|
||||
val backend = zygisk(installed(active = true))
|
||||
|
||||
assertEquals(
|
||||
LayerStatus.Active(hidden = 0, leaks = 0),
|
||||
summarizeNativeLayer(backend, outcomes),
|
||||
)
|
||||
assertEquals(
|
||||
LayerStatus.Active(hidden = 0, leaks = 1),
|
||||
summarizeNativeLayer(
|
||||
backend,
|
||||
outcomes,
|
||||
installedOptionalHooks = setOf(HookIds.Hook.FILESYSTEM_IFACE_PATHS),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ownership is per backend family - zygisk owns what the kernel does not`() {
|
||||
// Mirror image of the kmod case: proc_dev (zygisk openat) is zygisk-owned;
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ class StorageConfigTest {
|
|||
"zygisk_recvfrom",
|
||||
"zygisk_recvfrom_chk",
|
||||
"zygisk_setsockopt",
|
||||
"filesystem_iface_paths",
|
||||
),
|
||||
ZygiskNativeHookEntries.map { it.hookName },
|
||||
)
|
||||
|
|
|
|||
|
|
@ -17,6 +17,15 @@ All hooks are inline on `libc.so` via ByteDance shadowhook:
|
|||
| `recvmsg` | Netlink `RTM_NEWADDR` / `RTM_NEWLINK` dump responses | Removes VPN interface entries from netlink messages. |
|
||||
| `recv` | Netlink `RTM_NEWADDR` / `RTM_NEWLINK` dump responses | Same as `recvmsg`; hooked separately because bionic's `recv()` tail-calls `recvfrom`. |
|
||||
| `recvfrom`, `__recvfrom_chk` | Direct/FORTIFY'd netlink reads | Same filtering for callers that bypass the plain `recv` symbol. |
|
||||
| `open*`, `stat`/`lstat`/`fstat`/`fstatat`, `access*`, `readlink*`, `readdir*` | `/sys/.../net/<iface>` and `/proc/sys/net/{ipv4,ipv6}/{conf,neigh}/<iface>` | Optional best-effort group: returns `ENOENT` for VPN paths and removes VPN entries from directory listings. |
|
||||
|
||||
The filesystem group is controlled by the canonical
|
||||
`settings.optionalFeatures: ["filesystem_iface_paths"]` setting. The activator
|
||||
projects hook bit 27 only into selected Zygisk target masks, and a force-stop +
|
||||
restart is enough to apply a change. The group is installed atomically: if any
|
||||
required libc symbol cannot be hooked, its stubs are rolled back, bit 27 is
|
||||
omitted from the heartbeat's `installed_hooks`, and the ordinary network hooks
|
||||
continue working.
|
||||
|
||||
## Architecture
|
||||
|
||||
|
|
@ -27,7 +36,7 @@ PLT hooks patch the caller library's procedure linkage table. At `post_app_speci
|
|||
### Flow
|
||||
|
||||
1. **`pre_app_specialize`** -- runs in the already-forked child, before the kernel drops it to the app's UID and SELinux context (still has zygote privileges at this point). Reads `args.nice_name`, checks against the module-dir `targets.txt` runtime wire generated from `/data/system/vpnhide_config.json` by the activator. Non-targeted apps get `DlCloseModuleLibrary` (zero cost after unload). See `src/lib.rs`'s top-level doc block for the full Zygisk lifecycle and why every Rust `static` is fresh per app launch.
|
||||
2. **`post_app_specialize`** -- on targeted processes only: `shadowhook_init`, install the selected inline hooks (`ioctl`, `getifaddrs`, `setsockopt`, `openat`, `recvmsg`, `recv`, `recvfrom`, `__recvfrom_chk`), then scrub maps. `recv` is hooked separately because bionic's `recv()` is `b recvfrom` (tail-call) — patching `recvfrom`'s prologue would break `recv`.
|
||||
2. **`post_app_specialize`** -- on targeted processes only: `shadowhook_init`, install the selected inline hooks (`ioctl`, `getifaddrs`, `setsockopt`, `openat`, `recvmsg`, `recv`, `recvfrom`, `__recvfrom_chk`, and the optional filesystem group), then scrub maps. `recv` is hooked separately because bionic's `recv()` is `b recvfrom` (tail-call) — patching `recvfrom`'s prologue would break `recv`.
|
||||
|
||||
### Thread-local guard
|
||||
|
||||
|
|
@ -121,6 +130,11 @@ VPN interface prefixes: `tun`, `ppp`, `tap`, `wg`, `ipsec`, `xfrm`, `utun`, `l2t
|
|||
## Known limitations
|
||||
|
||||
- **Direct `svc #0` syscalls bypass the hook.** Apps issuing raw syscalls skip libc entirely, including the best-effort `setsockopt` protection. Use a kernel-level backend, [vpnhide-kmod](../kmod/) or [KPM](../kmod/kpm/), for these apps.
|
||||
- **Filesystem hiding is best-effort.** The optional group handles canonical
|
||||
and relative libc paths, but raw `openat`/`getdents64` syscalls, unhooked libc
|
||||
aliases, bind mounts, and paths reached through unrelated symlink aliases can
|
||||
bypass it. The kernel backends classify resolved dentries and provide the
|
||||
stronger contract.
|
||||
- **Socket binding is left native before Linux 5.7.** Those kernels reject an unprivileged `SO_BINDTODEVICE` before reading the name. Android common 5.4 backports `SO_BINDTOIFINDEX` with the same capability gate; 4.x lacks it. Filtering would introduce a distinguishable error instead of closing an oracle.
|
||||
- **arm64 only.** No 32-bit arm, no x86.
|
||||
- **`getifaddrs` hook leaks a few bytes per call.** Unlinked VPN entries in the ifaddrs linked list are intentionally leaked rather than tracked with a shadow allocator. Acceptable tradeoff -- `getifaddrs` is called infrequently.
|
||||
|
|
@ -130,6 +144,7 @@ VPN interface prefixes: `tun`, `ppp`, `tap`, `wg`, `ipsec`, `xfrm`, `utun`, `l2t
|
|||
|
||||
- `src/lib.rs` -- module entry point, target gating, hook installer, maps scrubbing
|
||||
- `src/hooks.rs` -- hook replacements for ioctl, getifaddrs, setsockopt, openat, and the recv family
|
||||
- `src/filesystem.rs` -- optional best-effort libc filesystem path and directory filtering
|
||||
- `src/filter.rs` -- VPN interface name matching and proc/net content filters (unit tested)
|
||||
- `src/shadowhook.rs` -- minimal FFI to shadowhook
|
||||
- `build.rs` -- drives CMake on the shadowhook submodule
|
||||
|
|
|
|||
755
zygisk/src/filesystem.rs
Normal file
755
zygisk/src/filesystem.rs
Normal file
|
|
@ -0,0 +1,755 @@
|
|||
//! Best-effort libc view for the optional filesystem interface-path feature.
|
||||
//!
|
||||
//! Kernel backends classify resolved dentries. Zygisk cannot provide that
|
||||
//! contract: raw syscalls and paths reached through unrelated bind mounts or
|
||||
//! symlink aliases bypass these hooks. It still closes the ordinary bionic
|
||||
//! paths used by Java, Rust, Dart, and native libraries in a target process.
|
||||
|
||||
use core::ffi::{c_char, c_int, c_void};
|
||||
use core::sync::atomic::{AtomicPtr, Ordering};
|
||||
use core::{mem, ptr, slice};
|
||||
|
||||
use crate::filter::is_vpn_iface_bytes;
|
||||
use crate::hooks::{filesystem_enabled, get_errno, set_errno};
|
||||
|
||||
const MAX_CALLER_PATH: usize = 1024;
|
||||
const MAX_RESOLVED_PATH: usize = 2048;
|
||||
// Split remote reads at 4 KiB boundaries. This is conservative on Android
|
||||
// devices with larger pages and prevents a valid path from sharing one read
|
||||
// with the next potentially-unmapped 4 KiB region.
|
||||
const COPY_CHUNK_BOUNDARY: usize = 4096;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum PathKind {
|
||||
Other,
|
||||
InterfaceListing,
|
||||
HiddenInterface,
|
||||
}
|
||||
|
||||
pub(crate) struct CallerPath {
|
||||
bytes: [u8; MAX_CALLER_PATH],
|
||||
len: usize,
|
||||
}
|
||||
|
||||
impl CallerPath {
|
||||
pub(crate) fn as_bytes(&self) -> &[u8] {
|
||||
&self.bytes[..self.len]
|
||||
}
|
||||
}
|
||||
|
||||
struct ResolvedPath {
|
||||
bytes: [u8; MAX_RESOLVED_PATH],
|
||||
len: usize,
|
||||
}
|
||||
|
||||
impl ResolvedPath {
|
||||
fn as_bytes(&self) -> &[u8] {
|
||||
&self.bytes[..self.len]
|
||||
}
|
||||
}
|
||||
|
||||
/// Read an untrusted NUL-terminated path without dereferencing it in-process.
|
||||
/// A bad caller pointer must retain libc's normal EFAULT instead of crashing
|
||||
/// the target app inside the hook.
|
||||
pub(crate) fn read_caller_path(path: *const c_char) -> Option<CallerPath> {
|
||||
if path.is_null() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut result = CallerPath {
|
||||
bytes: [0; MAX_CALLER_PATH],
|
||||
len: 0,
|
||||
};
|
||||
while result.len < result.bytes.len() {
|
||||
let remote_addr = path as usize + result.len;
|
||||
let page_left = COPY_CHUNK_BOUNDARY - (remote_addr & (COPY_CHUNK_BOUNDARY - 1));
|
||||
let wanted = page_left.min(result.bytes.len() - result.len);
|
||||
let local = libc::iovec {
|
||||
iov_base: result.bytes[result.len..].as_mut_ptr().cast(),
|
||||
iov_len: wanted,
|
||||
};
|
||||
let remote = libc::iovec {
|
||||
iov_base: remote_addr as *mut c_void,
|
||||
iov_len: wanted,
|
||||
};
|
||||
let copied = unsafe {
|
||||
libc::syscall(
|
||||
libc::SYS_process_vm_readv,
|
||||
libc::getpid(),
|
||||
ptr::from_ref(&local),
|
||||
1usize,
|
||||
ptr::from_ref(&remote),
|
||||
1usize,
|
||||
0usize,
|
||||
)
|
||||
};
|
||||
if copied <= 0 {
|
||||
return None;
|
||||
}
|
||||
let copied = copied as usize;
|
||||
let start = result.len;
|
||||
result.len += copied;
|
||||
if let Some(nul) = result.bytes[start..result.len]
|
||||
.iter()
|
||||
.position(|byte| *byte == 0)
|
||||
{
|
||||
result.len = start + nul;
|
||||
return Some(result);
|
||||
}
|
||||
if copied < wanted {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn decimal(buf: &mut [u8], mut value: u32) -> usize {
|
||||
if value == 0 {
|
||||
buf[0] = b'0';
|
||||
return 1;
|
||||
}
|
||||
let mut reversed = [0u8; 10];
|
||||
let mut len = 0;
|
||||
while value != 0 {
|
||||
reversed[len] = b'0' + (value % 10) as u8;
|
||||
value /= 10;
|
||||
len += 1;
|
||||
}
|
||||
for index in 0..len {
|
||||
buf[index] = reversed[len - index - 1];
|
||||
}
|
||||
len
|
||||
}
|
||||
|
||||
fn readlink_raw(path: &[u8], output: &mut [u8]) -> Option<usize> {
|
||||
if path.len() + 1 > 64 {
|
||||
return None;
|
||||
}
|
||||
let mut nul_path = [0u8; 64];
|
||||
nul_path[..path.len()].copy_from_slice(path);
|
||||
let len = unsafe {
|
||||
libc::syscall(
|
||||
libc::SYS_readlinkat,
|
||||
libc::AT_FDCWD,
|
||||
nul_path.as_ptr().cast::<c_char>(),
|
||||
output.as_mut_ptr(),
|
||||
output.len(),
|
||||
)
|
||||
};
|
||||
(len >= 0).then_some(len as usize)
|
||||
}
|
||||
|
||||
fn resolve_fd_path(fd: c_int, output: &mut [u8]) -> Option<usize> {
|
||||
if fd == libc::AT_FDCWD {
|
||||
return readlink_raw(b"/proc/self/cwd", output);
|
||||
}
|
||||
if fd < 0 {
|
||||
return None;
|
||||
}
|
||||
let prefix = b"/proc/self/fd/";
|
||||
let mut path = [0u8; 32];
|
||||
path[..prefix.len()].copy_from_slice(prefix);
|
||||
let digits = decimal(&mut path[prefix.len()..], fd as u32);
|
||||
readlink_raw(&path[..prefix.len() + digits], output)
|
||||
}
|
||||
|
||||
fn normalize_absolute(input: &[u8]) -> Option<ResolvedPath> {
|
||||
if input.first() != Some(&b'/') {
|
||||
return None;
|
||||
}
|
||||
let mut result = ResolvedPath {
|
||||
bytes: [0; MAX_RESOLVED_PATH],
|
||||
len: 1,
|
||||
};
|
||||
result.bytes[0] = b'/';
|
||||
let mut component_starts = [0usize; 96];
|
||||
let mut depth = 0usize;
|
||||
let mut cursor = 1usize;
|
||||
|
||||
while cursor < input.len() {
|
||||
while input.get(cursor) == Some(&b'/') {
|
||||
cursor += 1;
|
||||
}
|
||||
if cursor == input.len() {
|
||||
break;
|
||||
}
|
||||
let start = cursor;
|
||||
while cursor < input.len() && input[cursor] != b'/' {
|
||||
cursor += 1;
|
||||
}
|
||||
let component = &input[start..cursor];
|
||||
if component == b"." {
|
||||
continue;
|
||||
}
|
||||
if component == b".." {
|
||||
if depth != 0 {
|
||||
depth -= 1;
|
||||
result.len = component_starts[depth];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if depth == component_starts.len() {
|
||||
return None;
|
||||
}
|
||||
component_starts[depth] = result.len;
|
||||
depth += 1;
|
||||
if result.len != 1 {
|
||||
if result.len == result.bytes.len() {
|
||||
return None;
|
||||
}
|
||||
result.bytes[result.len] = b'/';
|
||||
result.len += 1;
|
||||
}
|
||||
if component.len() > result.bytes.len() - result.len {
|
||||
return None;
|
||||
}
|
||||
result.bytes[result.len..result.len + component.len()].copy_from_slice(component);
|
||||
result.len += component.len();
|
||||
}
|
||||
Some(result)
|
||||
}
|
||||
|
||||
fn resolve_path_at(dirfd: c_int, path: &[u8], allow_empty: bool) -> Option<ResolvedPath> {
|
||||
if path.first() == Some(&b'/') {
|
||||
return normalize_absolute(path);
|
||||
}
|
||||
if path.is_empty() && !allow_empty {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut combined = [0u8; MAX_RESOLVED_PATH];
|
||||
let base_len = resolve_fd_path(dirfd, &mut combined)?;
|
||||
if base_len == 0 || base_len >= combined.len() || combined[0] != b'/' {
|
||||
return None;
|
||||
}
|
||||
let mut len = base_len;
|
||||
if !path.is_empty() {
|
||||
if len + 1 + path.len() > combined.len() {
|
||||
return None;
|
||||
}
|
||||
combined[len] = b'/';
|
||||
len += 1;
|
||||
combined[len..len + path.len()].copy_from_slice(path);
|
||||
len += path.len();
|
||||
}
|
||||
normalize_absolute(&combined[..len])
|
||||
}
|
||||
|
||||
fn proc_listing(path: &[u8]) -> bool {
|
||||
matches!(
|
||||
path,
|
||||
b"/proc/sys/net/ipv4/conf"
|
||||
| b"/proc/sys/net/ipv4/neigh"
|
||||
| b"/proc/sys/net/ipv6/conf"
|
||||
| b"/proc/sys/net/ipv6/neigh"
|
||||
)
|
||||
}
|
||||
|
||||
fn proc_hidden(path: &[u8]) -> bool {
|
||||
const PREFIXES: [&[u8]; 4] = [
|
||||
b"/proc/sys/net/ipv4/conf/",
|
||||
b"/proc/sys/net/ipv4/neigh/",
|
||||
b"/proc/sys/net/ipv6/conf/",
|
||||
b"/proc/sys/net/ipv6/neigh/",
|
||||
];
|
||||
PREFIXES.iter().any(|prefix| {
|
||||
path.strip_prefix(*prefix).is_some_and(|tail| {
|
||||
let end = tail
|
||||
.iter()
|
||||
.position(|byte| *byte == b'/')
|
||||
.unwrap_or(tail.len());
|
||||
end != 0 && is_vpn_iface_bytes(&tail[..end])
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn classify_path(path: &[u8]) -> PathKind {
|
||||
if proc_hidden(path) {
|
||||
return PathKind::HiddenInterface;
|
||||
}
|
||||
if proc_listing(path) {
|
||||
return PathKind::InterfaceListing;
|
||||
}
|
||||
if !path.starts_with(b"/sys/") {
|
||||
return PathKind::Other;
|
||||
}
|
||||
|
||||
let mut cursor = 0usize;
|
||||
while let Some(relative) = path.get(cursor..).and_then(|tail| {
|
||||
tail.windows(5)
|
||||
.position(|window| window == b"/net/")
|
||||
.map(|position| cursor + position + 5)
|
||||
}) {
|
||||
let tail = &path[relative..];
|
||||
let end = tail
|
||||
.iter()
|
||||
.position(|byte| *byte == b'/')
|
||||
.unwrap_or(tail.len());
|
||||
if end != 0 && is_vpn_iface_bytes(&tail[..end]) {
|
||||
return PathKind::HiddenInterface;
|
||||
}
|
||||
cursor = relative;
|
||||
if cursor >= path.len() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if path == b"/sys/net" || path.ends_with(b"/net") {
|
||||
PathKind::InterfaceListing
|
||||
} else {
|
||||
PathKind::Other
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn classify_c_path_at(dirfd: c_int, path: *const c_char, allow_empty: bool) -> PathKind {
|
||||
let Some(path) = read_caller_path(path) else {
|
||||
return PathKind::Other;
|
||||
};
|
||||
resolve_path_at(dirfd, path.as_bytes(), allow_empty)
|
||||
.map_or(PathKind::Other, |path| classify_path(path.as_bytes()))
|
||||
}
|
||||
|
||||
pub(crate) fn hidden_path(dirfd: c_int, path: *const c_char, allow_empty: bool) -> bool {
|
||||
if !filesystem_enabled() {
|
||||
return false;
|
||||
}
|
||||
let saved_errno = get_errno();
|
||||
let hidden = classify_c_path_at(dirfd, path, allow_empty) == PathKind::HiddenInterface;
|
||||
set_errno(saved_errno);
|
||||
hidden
|
||||
}
|
||||
|
||||
fn hidden_fd(fd: c_int) -> bool {
|
||||
if !filesystem_enabled() {
|
||||
return false;
|
||||
}
|
||||
let saved_errno = get_errno();
|
||||
let mut path = [0u8; MAX_RESOLVED_PATH];
|
||||
let hidden = resolve_fd_path(fd, &mut path)
|
||||
.and_then(|len| normalize_absolute(&path[..len]))
|
||||
.is_some_and(|path| classify_path(path.as_bytes()) == PathKind::HiddenInterface);
|
||||
set_errno(saved_errno);
|
||||
hidden
|
||||
}
|
||||
|
||||
fn listing_fd(fd: c_int) -> bool {
|
||||
if !filesystem_enabled() {
|
||||
return false;
|
||||
}
|
||||
let saved_errno = get_errno();
|
||||
let mut path = [0u8; MAX_RESOLVED_PATH];
|
||||
let listing = resolve_fd_path(fd, &mut path)
|
||||
.and_then(|len| normalize_absolute(&path[..len]))
|
||||
.is_some_and(|path| classify_path(path.as_bytes()) == PathKind::InterfaceListing);
|
||||
set_errno(saved_errno);
|
||||
listing
|
||||
}
|
||||
|
||||
macro_rules! saved_original {
|
||||
($ty:ident, $slot:ident, $getter:ident, $setter:ident, $sig:ty) => {
|
||||
type $ty = $sig;
|
||||
static $slot: AtomicPtr<c_void> = AtomicPtr::new(ptr::null_mut());
|
||||
|
||||
fn $getter() -> Option<$ty> {
|
||||
let raw = $slot.load(Ordering::Relaxed);
|
||||
(!raw.is_null()).then(|| unsafe { mem::transmute::<*mut c_void, $ty>(raw) })
|
||||
}
|
||||
|
||||
pub(crate) fn $setter(pointer: *const ()) {
|
||||
$slot.store(pointer as *mut c_void, Ordering::Relaxed);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
saved_original!(
|
||||
OpenFn,
|
||||
REAL_OPEN,
|
||||
real_open,
|
||||
set_real_open_ptr,
|
||||
unsafe extern "C" fn(*const c_char, c_int, libc::mode_t) -> c_int
|
||||
);
|
||||
saved_original!(
|
||||
Open64Fn,
|
||||
REAL_OPEN64,
|
||||
real_open64,
|
||||
set_real_open64_ptr,
|
||||
unsafe extern "C" fn(*const c_char, c_int, libc::mode_t) -> c_int
|
||||
);
|
||||
saved_original!(
|
||||
Openat64Fn,
|
||||
REAL_OPENAT64,
|
||||
real_openat64,
|
||||
set_real_openat64_ptr,
|
||||
unsafe extern "C" fn(c_int, *const c_char, c_int, libc::mode_t) -> c_int
|
||||
);
|
||||
saved_original!(
|
||||
Open2Fn,
|
||||
REAL_OPEN_2,
|
||||
real_open_2,
|
||||
set_real_open_2_ptr,
|
||||
unsafe extern "C" fn(*const c_char, c_int) -> c_int
|
||||
);
|
||||
saved_original!(
|
||||
Openat2Fn,
|
||||
REAL_OPENAT_2,
|
||||
real_openat_2,
|
||||
set_real_openat_2_ptr,
|
||||
unsafe extern "C" fn(c_int, *const c_char, c_int) -> c_int
|
||||
);
|
||||
saved_original!(
|
||||
AccessFn,
|
||||
REAL_ACCESS,
|
||||
real_access,
|
||||
set_real_access_ptr,
|
||||
unsafe extern "C" fn(*const c_char, c_int) -> c_int
|
||||
);
|
||||
saved_original!(
|
||||
FaccessatFn,
|
||||
REAL_FACCESSAT,
|
||||
real_faccessat,
|
||||
set_real_faccessat_ptr,
|
||||
unsafe extern "C" fn(c_int, *const c_char, c_int, c_int) -> c_int
|
||||
);
|
||||
saved_original!(
|
||||
StatFn,
|
||||
REAL_STAT,
|
||||
real_stat,
|
||||
set_real_stat_ptr,
|
||||
unsafe extern "C" fn(*const c_char, *mut libc::stat) -> c_int
|
||||
);
|
||||
saved_original!(
|
||||
Stat64Fn,
|
||||
REAL_STAT64,
|
||||
real_stat64,
|
||||
set_real_stat64_ptr,
|
||||
unsafe extern "C" fn(*const c_char, *mut libc::stat) -> c_int
|
||||
);
|
||||
saved_original!(
|
||||
LstatFn,
|
||||
REAL_LSTAT,
|
||||
real_lstat,
|
||||
set_real_lstat_ptr,
|
||||
unsafe extern "C" fn(*const c_char, *mut libc::stat) -> c_int
|
||||
);
|
||||
saved_original!(
|
||||
Lstat64Fn,
|
||||
REAL_LSTAT64,
|
||||
real_lstat64,
|
||||
set_real_lstat64_ptr,
|
||||
unsafe extern "C" fn(*const c_char, *mut libc::stat) -> c_int
|
||||
);
|
||||
saved_original!(
|
||||
FstatFn,
|
||||
REAL_FSTAT,
|
||||
real_fstat,
|
||||
set_real_fstat_ptr,
|
||||
unsafe extern "C" fn(c_int, *mut libc::stat) -> c_int
|
||||
);
|
||||
saved_original!(
|
||||
Fstat64Fn,
|
||||
REAL_FSTAT64,
|
||||
real_fstat64,
|
||||
set_real_fstat64_ptr,
|
||||
unsafe extern "C" fn(c_int, *mut libc::stat) -> c_int
|
||||
);
|
||||
saved_original!(
|
||||
FstatatFn,
|
||||
REAL_FSTATAT,
|
||||
real_fstatat,
|
||||
set_real_fstatat_ptr,
|
||||
unsafe extern "C" fn(c_int, *const c_char, *mut libc::stat, c_int) -> c_int
|
||||
);
|
||||
saved_original!(
|
||||
Fstatat64Fn,
|
||||
REAL_FSTATAT64,
|
||||
real_fstatat64,
|
||||
set_real_fstatat64_ptr,
|
||||
unsafe extern "C" fn(c_int, *const c_char, *mut libc::stat, c_int) -> c_int
|
||||
);
|
||||
saved_original!(
|
||||
ReadlinkFn,
|
||||
REAL_READLINK,
|
||||
real_readlink,
|
||||
set_real_readlink_ptr,
|
||||
unsafe extern "C" fn(*const c_char, *mut c_char, usize) -> libc::ssize_t
|
||||
);
|
||||
saved_original!(
|
||||
ReadlinkatFn,
|
||||
REAL_READLINKAT,
|
||||
real_readlinkat,
|
||||
set_real_readlinkat_ptr,
|
||||
unsafe extern "C" fn(c_int, *const c_char, *mut c_char, usize) -> libc::ssize_t
|
||||
);
|
||||
saved_original!(
|
||||
ReadlinkChkFn,
|
||||
REAL_READLINK_CHK,
|
||||
real_readlink_chk,
|
||||
set_real_readlink_chk_ptr,
|
||||
unsafe extern "C" fn(*const c_char, *mut c_char, usize, usize) -> libc::ssize_t
|
||||
);
|
||||
saved_original!(
|
||||
ReadlinkatChkFn,
|
||||
REAL_READLINKAT_CHK,
|
||||
real_readlinkat_chk,
|
||||
set_real_readlinkat_chk_ptr,
|
||||
unsafe extern "C" fn(c_int, *const c_char, *mut c_char, usize, usize) -> libc::ssize_t
|
||||
);
|
||||
saved_original!(
|
||||
ReaddirFn,
|
||||
REAL_READDIR,
|
||||
real_readdir,
|
||||
set_real_readdir_ptr,
|
||||
unsafe extern "C" fn(*mut libc::DIR) -> *mut libc::dirent
|
||||
);
|
||||
saved_original!(
|
||||
Readdir64Fn,
|
||||
REAL_READDIR64,
|
||||
real_readdir64,
|
||||
set_real_readdir64_ptr,
|
||||
unsafe extern "C" fn(*mut libc::DIR) -> *mut libc::dirent64
|
||||
);
|
||||
|
||||
fn denied() -> c_int {
|
||||
set_errno(libc::ENOENT);
|
||||
-1
|
||||
}
|
||||
|
||||
macro_rules! path_wrapper {
|
||||
($name:ident, $real:ident, ($($arg:ident : $ty:ty),*), $dirfd:expr, $path:expr, $allow_empty:expr, $call:expr) => {
|
||||
pub(crate) unsafe extern "C" fn $name($($arg: $ty),*) -> c_int {
|
||||
let Some(real) = $real() else {
|
||||
set_errno(libc::EFAULT);
|
||||
return -1;
|
||||
};
|
||||
if hidden_path($dirfd, $path, $allow_empty) {
|
||||
return denied();
|
||||
}
|
||||
unsafe { $call(real) }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
path_wrapper!(hooked_open, real_open, (path: *const c_char, flags: c_int, mode: libc::mode_t), libc::AT_FDCWD, path, false, |real: OpenFn| real(path, flags, mode));
|
||||
path_wrapper!(hooked_open64, real_open64, (path: *const c_char, flags: c_int, mode: libc::mode_t), libc::AT_FDCWD, path, false, |real: Open64Fn| real(path, flags, mode));
|
||||
path_wrapper!(hooked_openat64, real_openat64, (dirfd: c_int, path: *const c_char, flags: c_int, mode: libc::mode_t), dirfd, path, false, |real: Openat64Fn| real(dirfd, path, flags, mode));
|
||||
path_wrapper!(hooked_open_2, real_open_2, (path: *const c_char, flags: c_int), libc::AT_FDCWD, path, false, |real: Open2Fn| real(path, flags));
|
||||
path_wrapper!(hooked_openat_2, real_openat_2, (dirfd: c_int, path: *const c_char, flags: c_int), dirfd, path, false, |real: Openat2Fn| real(dirfd, path, flags));
|
||||
path_wrapper!(hooked_access, real_access, (path: *const c_char, mode: c_int), libc::AT_FDCWD, path, false, |real: AccessFn| real(path, mode));
|
||||
path_wrapper!(hooked_faccessat, real_faccessat, (dirfd: c_int, path: *const c_char, mode: c_int, flags: c_int), dirfd, path, flags & libc::AT_EMPTY_PATH != 0, |real: FaccessatFn| real(dirfd, path, mode, flags));
|
||||
path_wrapper!(hooked_stat, real_stat, (path: *const c_char, out: *mut libc::stat), libc::AT_FDCWD, path, false, |real: StatFn| real(path, out));
|
||||
path_wrapper!(hooked_stat64, real_stat64, (path: *const c_char, out: *mut libc::stat), libc::AT_FDCWD, path, false, |real: Stat64Fn| real(path, out));
|
||||
path_wrapper!(hooked_lstat, real_lstat, (path: *const c_char, out: *mut libc::stat), libc::AT_FDCWD, path, false, |real: LstatFn| real(path, out));
|
||||
path_wrapper!(hooked_lstat64, real_lstat64, (path: *const c_char, out: *mut libc::stat), libc::AT_FDCWD, path, false, |real: Lstat64Fn| real(path, out));
|
||||
pub(crate) unsafe extern "C" fn hooked_readlink(
|
||||
path: *const c_char,
|
||||
output: *mut c_char,
|
||||
size: usize,
|
||||
) -> libc::ssize_t {
|
||||
let Some(real) = real_readlink() else {
|
||||
set_errno(libc::EFAULT);
|
||||
return -1;
|
||||
};
|
||||
if hidden_path(libc::AT_FDCWD, path, false) {
|
||||
set_errno(libc::ENOENT);
|
||||
return -1;
|
||||
}
|
||||
unsafe { real(path, output, size) }
|
||||
}
|
||||
|
||||
pub(crate) unsafe extern "C" fn hooked_fstat(fd: c_int, out: *mut libc::stat) -> c_int {
|
||||
let Some(real) = real_fstat() else {
|
||||
set_errno(libc::EFAULT);
|
||||
return -1;
|
||||
};
|
||||
if hidden_fd(fd) {
|
||||
return denied();
|
||||
}
|
||||
unsafe { real(fd, out) }
|
||||
}
|
||||
|
||||
pub(crate) unsafe extern "C" fn hooked_fstat64(fd: c_int, out: *mut libc::stat) -> c_int {
|
||||
let Some(real) = real_fstat64() else {
|
||||
set_errno(libc::EFAULT);
|
||||
return -1;
|
||||
};
|
||||
if hidden_fd(fd) {
|
||||
return denied();
|
||||
}
|
||||
unsafe { real(fd, out) }
|
||||
}
|
||||
|
||||
pub(crate) unsafe extern "C" fn hooked_fstatat(
|
||||
dirfd: c_int,
|
||||
path: *const c_char,
|
||||
out: *mut libc::stat,
|
||||
flags: c_int,
|
||||
) -> c_int {
|
||||
let Some(real) = real_fstatat() else {
|
||||
set_errno(libc::EFAULT);
|
||||
return -1;
|
||||
};
|
||||
if hidden_path(dirfd, path, flags & libc::AT_EMPTY_PATH != 0) {
|
||||
return denied();
|
||||
}
|
||||
unsafe { real(dirfd, path, out, flags) }
|
||||
}
|
||||
|
||||
pub(crate) unsafe extern "C" fn hooked_fstatat64(
|
||||
dirfd: c_int,
|
||||
path: *const c_char,
|
||||
out: *mut libc::stat,
|
||||
flags: c_int,
|
||||
) -> c_int {
|
||||
let Some(real) = real_fstatat64() else {
|
||||
set_errno(libc::EFAULT);
|
||||
return -1;
|
||||
};
|
||||
if hidden_path(dirfd, path, flags & libc::AT_EMPTY_PATH != 0) {
|
||||
return denied();
|
||||
}
|
||||
unsafe { real(dirfd, path, out, flags) }
|
||||
}
|
||||
|
||||
pub(crate) unsafe extern "C" fn hooked_readlinkat(
|
||||
dirfd: c_int,
|
||||
path: *const c_char,
|
||||
output: *mut c_char,
|
||||
size: usize,
|
||||
) -> libc::ssize_t {
|
||||
let Some(real) = real_readlinkat() else {
|
||||
set_errno(libc::EFAULT);
|
||||
return -1;
|
||||
};
|
||||
if hidden_path(dirfd, path, false) {
|
||||
set_errno(libc::ENOENT);
|
||||
return -1;
|
||||
}
|
||||
unsafe { real(dirfd, path, output, size) }
|
||||
}
|
||||
|
||||
pub(crate) unsafe extern "C" fn hooked_readlink_chk(
|
||||
path: *const c_char,
|
||||
output: *mut c_char,
|
||||
size: usize,
|
||||
output_size: usize,
|
||||
) -> libc::ssize_t {
|
||||
let Some(real) = real_readlink_chk() else {
|
||||
set_errno(libc::EFAULT);
|
||||
return -1;
|
||||
};
|
||||
if hidden_path(libc::AT_FDCWD, path, false) {
|
||||
set_errno(libc::ENOENT);
|
||||
return -1;
|
||||
}
|
||||
unsafe { real(path, output, size, output_size) }
|
||||
}
|
||||
|
||||
pub(crate) unsafe extern "C" fn hooked_readlinkat_chk(
|
||||
dirfd: c_int,
|
||||
path: *const c_char,
|
||||
output: *mut c_char,
|
||||
size: usize,
|
||||
output_size: usize,
|
||||
) -> libc::ssize_t {
|
||||
let Some(real) = real_readlinkat_chk() else {
|
||||
set_errno(libc::EFAULT);
|
||||
return -1;
|
||||
};
|
||||
if hidden_path(dirfd, path, false) {
|
||||
set_errno(libc::ENOENT);
|
||||
return -1;
|
||||
}
|
||||
unsafe { real(dirfd, path, output, size, output_size) }
|
||||
}
|
||||
|
||||
unsafe fn dirent_is_vpn(entry: *const libc::dirent) -> bool {
|
||||
let name = unsafe { slice::from_raw_parts((*entry).d_name.as_ptr().cast::<u8>(), 256) };
|
||||
let len = name
|
||||
.iter()
|
||||
.position(|byte| *byte == 0)
|
||||
.unwrap_or(name.len());
|
||||
is_vpn_iface_bytes(&name[..len])
|
||||
}
|
||||
|
||||
unsafe fn dirent64_is_vpn(entry: *const libc::dirent64) -> bool {
|
||||
let name = unsafe { slice::from_raw_parts((*entry).d_name.as_ptr().cast::<u8>(), 256) };
|
||||
let len = name
|
||||
.iter()
|
||||
.position(|byte| *byte == 0)
|
||||
.unwrap_or(name.len());
|
||||
is_vpn_iface_bytes(&name[..len])
|
||||
}
|
||||
|
||||
pub(crate) unsafe extern "C" fn hooked_readdir(dir: *mut libc::DIR) -> *mut libc::dirent {
|
||||
let Some(real) = real_readdir() else {
|
||||
set_errno(libc::EFAULT);
|
||||
return ptr::null_mut();
|
||||
};
|
||||
let filter = !dir.is_null() && listing_fd(unsafe { libc::dirfd(dir) });
|
||||
loop {
|
||||
let entry = unsafe { real(dir) };
|
||||
if entry.is_null() || !filter || !unsafe { dirent_is_vpn(entry) } {
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) unsafe extern "C" fn hooked_readdir64(dir: *mut libc::DIR) -> *mut libc::dirent64 {
|
||||
let Some(real) = real_readdir64() else {
|
||||
set_errno(libc::EFAULT);
|
||||
return ptr::null_mut();
|
||||
};
|
||||
let filter = !dir.is_null() && listing_fd(unsafe { libc::dirfd(dir) });
|
||||
loop {
|
||||
let entry = unsafe { real(dir) };
|
||||
if entry.is_null() || !filter || !unsafe { dirent64_is_vpn(entry) } {
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn classifies_sysfs_interface_paths_and_listings() {
|
||||
assert_eq!(classify_path(b"/sys/class/net"), PathKind::InterfaceListing);
|
||||
assert_eq!(
|
||||
classify_path(b"/sys/class/net/tun0/statistics/rx_bytes"),
|
||||
PathKind::HiddenInterface,
|
||||
);
|
||||
assert_eq!(
|
||||
classify_path(b"/sys/devices/virtual/net/wg0"),
|
||||
PathKind::HiddenInterface,
|
||||
);
|
||||
assert_eq!(classify_path(b"/sys/class/net/wlan0"), PathKind::Other);
|
||||
assert_eq!(classify_path(b"/data/net/tun0"), PathKind::Other);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_proc_sys_interface_paths_and_listings() {
|
||||
assert_eq!(
|
||||
classify_path(b"/proc/sys/net/ipv4/conf"),
|
||||
PathKind::InterfaceListing,
|
||||
);
|
||||
assert_eq!(
|
||||
classify_path(b"/proc/sys/net/ipv6/neigh/tailscale0/gc_stale_time"),
|
||||
PathKind::HiddenInterface,
|
||||
);
|
||||
assert_eq!(
|
||||
classify_path(b"/proc/sys/net/ipv4/conf/default"),
|
||||
PathKind::Other,
|
||||
);
|
||||
assert_eq!(classify_path(b"/proc/net/tun0"), PathKind::Other);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_dot_segments_without_escaping_root() {
|
||||
let path = normalize_absolute(b"/sys/class/./net/../net//tun0/ifindex").unwrap();
|
||||
assert_eq!(path.as_bytes(), b"/sys/class/net/tun0/ifindex");
|
||||
assert_eq!(classify_path(path.as_bytes()), PathKind::HiddenInterface);
|
||||
|
||||
let path = normalize_absolute(b"/../../sys/class/net/wlan0").unwrap();
|
||||
assert_eq!(path.as_bytes(), b"/sys/class/net/wlan0");
|
||||
}
|
||||
}
|
||||
|
|
@ -60,7 +60,7 @@ pub enum Hook {
|
|||
SocketBindInterface = 25,
|
||||
/// libc setsockopt() best-effort socket-interface bind denial
|
||||
ZygiskSetsockopt = 26,
|
||||
/// Optional reboot-gated sysfs/proc-sys VPN interface path concealment
|
||||
/// Optional sysfs/proc-sys VPN interface path concealment
|
||||
FilesystemIfacePaths = 27,
|
||||
}
|
||||
|
||||
|
|
@ -146,7 +146,7 @@ pub const HOOK_COUNT: u32 = 28;
|
|||
pub const KERNEL_HOOK_MASK: u32 = 0x20003ff;
|
||||
pub const KMOD_HOOK_MASK: u32 = 0x8000000;
|
||||
pub const KPM_HOOK_MASK: u32 = 0x8000000;
|
||||
pub const ZYGISK_HOOK_MASK: u32 = 0x5fc0000;
|
||||
pub const ZYGISK_HOOK_MASK: u32 = 0xdfc0000;
|
||||
pub const LSPOSED_HOOK_MASK: u32 = 0x3fc00;
|
||||
|
||||
/// status error codes (protocol §5.1).
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
|
||||
use core::cell::{Cell, RefCell};
|
||||
use core::ffi::{CStr, c_int, c_void};
|
||||
use core::sync::atomic::{AtomicPtr, AtomicU8, Ordering};
|
||||
use core::sync::atomic::{AtomicPtr, AtomicU8, AtomicU32, Ordering};
|
||||
use core::{mem, ptr, slice};
|
||||
|
||||
use libc::{SIOCGIFCONF, SIOCGIFNAME, ifreq};
|
||||
|
|
@ -34,6 +34,21 @@ use crate::filter::{
|
|||
filter_ipv6_route_buf, filter_netlink_dump, filter_route_buf, filter_tcp4_buf, filter_tcp6_buf,
|
||||
is_vpn_iface_bytes, is_vpn_iface_cstr,
|
||||
};
|
||||
use crate::generated::hook_ids::Hook;
|
||||
|
||||
static ACTIVE_HOOKMASK: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
pub(crate) fn set_active_hookmask(hookmask: u32) {
|
||||
ACTIVE_HOOKMASK.store(hookmask, Ordering::Release);
|
||||
}
|
||||
|
||||
pub(crate) fn filesystem_enabled() -> bool {
|
||||
ACTIVE_HOOKMASK.load(Ordering::Acquire) & Hook::FilesystemIfacePaths.bit() != 0
|
||||
}
|
||||
|
||||
fn proc_net_open_enabled() -> bool {
|
||||
ACTIVE_HOOKMASK.load(Ordering::Acquire) & Hook::ZygiskOpenat.bit() != 0
|
||||
}
|
||||
|
||||
/// `struct ifconf` from `<net/if.h>`. Not exported by the `libc` crate.
|
||||
#[repr(C)]
|
||||
|
|
@ -112,7 +127,20 @@ unsafe extern "C" {
|
|||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn set_errno(val: c_int) {
|
||||
pub(crate) fn get_errno() -> c_int {
|
||||
#[cfg(target_os = "android")]
|
||||
unsafe {
|
||||
*__errno()
|
||||
}
|
||||
|
||||
#[cfg(all(not(target_os = "android"), target_os = "linux"))]
|
||||
unsafe {
|
||||
*__errno_location()
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub(crate) fn set_errno(val: c_int) {
|
||||
#[cfg(target_os = "android")]
|
||||
unsafe {
|
||||
*__errno() = val;
|
||||
|
|
@ -922,9 +950,16 @@ pub unsafe extern "C" fn hooked_openat(
|
|||
return -1;
|
||||
};
|
||||
|
||||
if !pathname.is_null() {
|
||||
let path = unsafe { CStr::from_ptr(pathname) };
|
||||
let path_bytes = path.to_bytes();
|
||||
if crate::filesystem::hidden_path(dirfd, pathname, false) {
|
||||
set_errno(libc::ENOENT);
|
||||
return -1;
|
||||
}
|
||||
|
||||
if proc_net_open_enabled() && !pathname.is_null() {
|
||||
let Some(path) = crate::filesystem::read_caller_path(pathname) else {
|
||||
return unsafe { real(dirfd, pathname, flags, mode) };
|
||||
};
|
||||
let path_bytes = path.as_bytes();
|
||||
|
||||
let matched = if path_bytes.first() == Some(&b'/') {
|
||||
match_abs_proc_net(path_bytes)
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@
|
|||
//! injects it into every forked app process; the `pre_app_specialize`
|
||||
//! filter ensures we only actually do work for targeted apps.
|
||||
|
||||
mod filesystem;
|
||||
mod filter;
|
||||
mod generated;
|
||||
mod hooks;
|
||||
|
|
@ -98,11 +99,24 @@ use zygisk_api::api::v2::{AppSpecializeArgs, V2, ZygiskOption};
|
|||
use zygisk_api::jni::{JNIEnv, objects::JString};
|
||||
use zygisk_api::{ZygiskModule, register_companion, register_module};
|
||||
|
||||
use crate::filesystem::{
|
||||
hooked_access, hooked_faccessat, hooked_fstat, hooked_fstat64, hooked_fstatat,
|
||||
hooked_fstatat64, hooked_lstat, hooked_lstat64, hooked_open, hooked_open_2, hooked_open64,
|
||||
hooked_openat_2, hooked_openat64, hooked_readdir, hooked_readdir64, hooked_readlink,
|
||||
hooked_readlink_chk, hooked_readlinkat, hooked_readlinkat_chk, hooked_stat, hooked_stat64,
|
||||
set_real_access_ptr, set_real_faccessat_ptr, set_real_fstat_ptr, set_real_fstat64_ptr,
|
||||
set_real_fstatat_ptr, set_real_fstatat64_ptr, set_real_lstat_ptr, set_real_lstat64_ptr,
|
||||
set_real_open_2_ptr, set_real_open_ptr, set_real_open64_ptr, set_real_openat_2_ptr,
|
||||
set_real_openat64_ptr, set_real_readdir_ptr, set_real_readdir64_ptr, set_real_readlink_chk_ptr,
|
||||
set_real_readlink_ptr, set_real_readlinkat_chk_ptr, set_real_readlinkat_ptr, set_real_stat_ptr,
|
||||
set_real_stat64_ptr,
|
||||
};
|
||||
use crate::hooks::{
|
||||
hooked_getifaddrs, hooked_ioctl, hooked_openat, hooked_recv, hooked_recvfrom,
|
||||
hooked_recvfrom_chk, hooked_recvmsg, hooked_setsockopt, set_real_getifaddrs_ptr,
|
||||
set_real_ioctl_ptr, set_real_openat_ptr, set_real_recv_ptr, set_real_recvfrom_chk_ptr,
|
||||
set_real_recvfrom_ptr, set_real_recvmsg_ptr, set_real_setsockopt_ptr,
|
||||
hooked_recvfrom_chk, hooked_recvmsg, hooked_setsockopt, set_active_hookmask,
|
||||
set_real_getifaddrs_ptr, set_real_ioctl_ptr, set_real_openat_ptr, set_real_recv_ptr,
|
||||
set_real_recvfrom_chk_ptr, set_real_recvfrom_ptr, set_real_recvmsg_ptr,
|
||||
set_real_setsockopt_ptr,
|
||||
};
|
||||
|
||||
const LOG_TAG: &str = "vpnhide-zygisk";
|
||||
|
|
@ -326,10 +340,13 @@ impl ZygiskModule for VpnHide {
|
|||
return;
|
||||
}
|
||||
match install_hooks(hookmask) {
|
||||
Ok(()) => {
|
||||
info!("selected libc hooks installed (mask=0x{hookmask:x})");
|
||||
Ok(report) => {
|
||||
info!(
|
||||
"selected libc hooks installed (requested=0x{:x} installed=0x{:x})",
|
||||
report.requested_mask, report.installed_mask,
|
||||
);
|
||||
if self.report_status.load(Ordering::Relaxed) {
|
||||
write_status_file();
|
||||
write_status_file(&report);
|
||||
}
|
||||
// Erase shadowhook's fingerprints from /proc/self/maps
|
||||
// before any anti-tamper SDK gets a chance to scan
|
||||
|
|
@ -344,7 +361,7 @@ impl ZygiskModule for VpnHide {
|
|||
// which is what we want: system_server isn't in scope for this module.
|
||||
}
|
||||
|
||||
fn write_status_file() {
|
||||
fn write_status_file(report: &HookInstallReport) {
|
||||
let boot_id = match fs::read_to_string("/proc/sys/kernel/random/boot_id") {
|
||||
Ok(v) => v.trim().to_string(),
|
||||
Err(err) => {
|
||||
|
|
@ -359,12 +376,20 @@ fn write_status_file() {
|
|||
return;
|
||||
}
|
||||
};
|
||||
let filesystem_error = report
|
||||
.filesystem_error
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.replace(['\n', '\r'], " ");
|
||||
let content = format!(
|
||||
"version={}\nboot_id={}\npid={}\ntimestamp={}\n",
|
||||
"version={}\nboot_id={}\npid={}\ntimestamp={}\nrequested_hooks={:x}\ninstalled_hooks={:x}\nfilesystem_error={}\n",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
boot_id,
|
||||
process::id(),
|
||||
timestamp,
|
||||
report.requested_mask,
|
||||
report.installed_mask,
|
||||
filesystem_error,
|
||||
);
|
||||
if let Some(parent) = Path::new(APP_STATUS_FILE).parent() {
|
||||
if let Err(err) = fs::create_dir_all(parent) {
|
||||
|
|
@ -391,6 +416,13 @@ struct HookDesc {
|
|||
store_orig: fn(*const ()),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct HookInstallReport {
|
||||
requested_mask: u32,
|
||||
installed_mask: u32,
|
||||
filesystem_error: Option<String>,
|
||||
}
|
||||
|
||||
/// Install selected inline hooks on `libc.so` via ByteDance shadowhook.
|
||||
///
|
||||
/// * `ioctl` — catches `SIOCGIFNAME` / `SIOCGIFFLAGS` interface
|
||||
|
|
@ -414,7 +446,7 @@ struct HookDesc {
|
|||
/// excludes `libflutter.so`/`libapp.so` and any other library loaded later
|
||||
/// via `dlopen`. Inline-hooking libc's entry points themselves catches
|
||||
/// every caller regardless of load order.
|
||||
fn install_hooks(hookmask: u32) -> Result<(), String> {
|
||||
fn install_hooks(hookmask: u32) -> Result<HookInstallReport, String> {
|
||||
shadowhook::init_once().map_err(|rc| format!("shadowhook_init: rc={rc}"))?;
|
||||
|
||||
// Every libc symbol the Zygisk backend can hook, in install order (we roll
|
||||
|
|
@ -427,7 +459,7 @@ fn install_hooks(hookmask: u32) -> Result<(), String> {
|
|||
// itself is 12 bytes (3 instructions), safe for island-mode hooking.
|
||||
// recvfrom + __recvfrom_chk catch FORTIFY'd / direct callers that never
|
||||
// touch the `recv` symbol (issue #86 native route dump).
|
||||
let table = [
|
||||
let base_table = [
|
||||
HookDesc {
|
||||
sym: c"ioctl",
|
||||
hook_id: Hook::ZygiskIoctl,
|
||||
|
|
@ -446,12 +478,6 @@ fn install_hooks(hookmask: u32) -> Result<(), String> {
|
|||
replacement: hooked_setsockopt as *mut _,
|
||||
store_orig: set_real_setsockopt_ptr,
|
||||
},
|
||||
HookDesc {
|
||||
sym: c"openat",
|
||||
hook_id: Hook::ZygiskOpenat,
|
||||
replacement: hooked_openat as *mut _,
|
||||
store_orig: set_real_openat_ptr,
|
||||
},
|
||||
HookDesc {
|
||||
sym: c"recvmsg",
|
||||
hook_id: Hook::ZygiskRecvmsg,
|
||||
|
|
@ -478,33 +504,210 @@ fn install_hooks(hookmask: u32) -> Result<(), String> {
|
|||
},
|
||||
];
|
||||
|
||||
let mut installed: Vec<*mut c_void> = Vec::with_capacity(table.len());
|
||||
for desc in table {
|
||||
let mut base_stubs: Vec<*mut c_void> = Vec::with_capacity(base_table.len() + 1);
|
||||
for desc in base_table {
|
||||
if !zygisk_hook_enabled(hookmask, desc.hook_id) {
|
||||
continue;
|
||||
}
|
||||
match hook_libc_sym(desc.sym, desc.replacement, desc.store_orig) {
|
||||
Ok(stub) => installed.push(stub),
|
||||
Ok(stub) => base_stubs.push(stub),
|
||||
Err(err) => {
|
||||
// Roll back any hooks already installed before this
|
||||
// one failed — better to be fully off than to leave
|
||||
// the process with a torn hook plan that filters
|
||||
// some libc paths but not others.
|
||||
for stub in installed.into_iter().rev() {
|
||||
let rc = unsafe { shadowhook::unhook(stub) };
|
||||
if rc != 0 {
|
||||
// Nothing useful to do — we're already on
|
||||
// the error path. Surface it via log so a
|
||||
// bad install at least leaves a trail.
|
||||
error!("install_hooks rollback: shadowhook_unhook rc={rc}");
|
||||
}
|
||||
}
|
||||
rollback_hooks(&mut base_stubs);
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
let proc_open_requested = zygisk_hook_enabled(hookmask, Hook::ZygiskOpenat);
|
||||
let filesystem_requested = zygisk_hook_enabled(hookmask, Hook::FilesystemIfacePaths);
|
||||
let mut shared_openat_stub = None;
|
||||
let mut filesystem_error = None;
|
||||
if proc_open_requested || filesystem_requested {
|
||||
match hook_libc_sym(c"openat", hooked_openat as *mut _, set_real_openat_ptr) {
|
||||
Ok(stub) => shared_openat_stub = Some(stub),
|
||||
Err(err) if proc_open_requested => {
|
||||
rollback_hooks(&mut base_stubs);
|
||||
return Err(err);
|
||||
}
|
||||
Err(err) => filesystem_error = Some(err),
|
||||
}
|
||||
}
|
||||
|
||||
let filesystem_table = [
|
||||
HookDesc {
|
||||
sym: c"open",
|
||||
hook_id: Hook::FilesystemIfacePaths,
|
||||
replacement: hooked_open as *mut _,
|
||||
store_orig: set_real_open_ptr,
|
||||
},
|
||||
HookDesc {
|
||||
sym: c"open64",
|
||||
hook_id: Hook::FilesystemIfacePaths,
|
||||
replacement: hooked_open64 as *mut _,
|
||||
store_orig: set_real_open64_ptr,
|
||||
},
|
||||
HookDesc {
|
||||
sym: c"openat64",
|
||||
hook_id: Hook::FilesystemIfacePaths,
|
||||
replacement: hooked_openat64 as *mut _,
|
||||
store_orig: set_real_openat64_ptr,
|
||||
},
|
||||
HookDesc {
|
||||
sym: c"__open_2",
|
||||
hook_id: Hook::FilesystemIfacePaths,
|
||||
replacement: hooked_open_2 as *mut _,
|
||||
store_orig: set_real_open_2_ptr,
|
||||
},
|
||||
HookDesc {
|
||||
sym: c"__openat_2",
|
||||
hook_id: Hook::FilesystemIfacePaths,
|
||||
replacement: hooked_openat_2 as *mut _,
|
||||
store_orig: set_real_openat_2_ptr,
|
||||
},
|
||||
HookDesc {
|
||||
sym: c"access",
|
||||
hook_id: Hook::FilesystemIfacePaths,
|
||||
replacement: hooked_access as *mut _,
|
||||
store_orig: set_real_access_ptr,
|
||||
},
|
||||
HookDesc {
|
||||
sym: c"faccessat",
|
||||
hook_id: Hook::FilesystemIfacePaths,
|
||||
replacement: hooked_faccessat as *mut _,
|
||||
store_orig: set_real_faccessat_ptr,
|
||||
},
|
||||
HookDesc {
|
||||
sym: c"stat",
|
||||
hook_id: Hook::FilesystemIfacePaths,
|
||||
replacement: hooked_stat as *mut _,
|
||||
store_orig: set_real_stat_ptr,
|
||||
},
|
||||
HookDesc {
|
||||
sym: c"stat64",
|
||||
hook_id: Hook::FilesystemIfacePaths,
|
||||
replacement: hooked_stat64 as *mut _,
|
||||
store_orig: set_real_stat64_ptr,
|
||||
},
|
||||
HookDesc {
|
||||
sym: c"lstat",
|
||||
hook_id: Hook::FilesystemIfacePaths,
|
||||
replacement: hooked_lstat as *mut _,
|
||||
store_orig: set_real_lstat_ptr,
|
||||
},
|
||||
HookDesc {
|
||||
sym: c"lstat64",
|
||||
hook_id: Hook::FilesystemIfacePaths,
|
||||
replacement: hooked_lstat64 as *mut _,
|
||||
store_orig: set_real_lstat64_ptr,
|
||||
},
|
||||
HookDesc {
|
||||
sym: c"fstat",
|
||||
hook_id: Hook::FilesystemIfacePaths,
|
||||
replacement: hooked_fstat as *mut _,
|
||||
store_orig: set_real_fstat_ptr,
|
||||
},
|
||||
HookDesc {
|
||||
sym: c"fstat64",
|
||||
hook_id: Hook::FilesystemIfacePaths,
|
||||
replacement: hooked_fstat64 as *mut _,
|
||||
store_orig: set_real_fstat64_ptr,
|
||||
},
|
||||
HookDesc {
|
||||
sym: c"fstatat",
|
||||
hook_id: Hook::FilesystemIfacePaths,
|
||||
replacement: hooked_fstatat as *mut _,
|
||||
store_orig: set_real_fstatat_ptr,
|
||||
},
|
||||
HookDesc {
|
||||
sym: c"fstatat64",
|
||||
hook_id: Hook::FilesystemIfacePaths,
|
||||
replacement: hooked_fstatat64 as *mut _,
|
||||
store_orig: set_real_fstatat64_ptr,
|
||||
},
|
||||
HookDesc {
|
||||
sym: c"readlink",
|
||||
hook_id: Hook::FilesystemIfacePaths,
|
||||
replacement: hooked_readlink as *mut _,
|
||||
store_orig: set_real_readlink_ptr,
|
||||
},
|
||||
HookDesc {
|
||||
sym: c"readlinkat",
|
||||
hook_id: Hook::FilesystemIfacePaths,
|
||||
replacement: hooked_readlinkat as *mut _,
|
||||
store_orig: set_real_readlinkat_ptr,
|
||||
},
|
||||
HookDesc {
|
||||
sym: c"__readlink_chk",
|
||||
hook_id: Hook::FilesystemIfacePaths,
|
||||
replacement: hooked_readlink_chk as *mut _,
|
||||
store_orig: set_real_readlink_chk_ptr,
|
||||
},
|
||||
HookDesc {
|
||||
sym: c"__readlinkat_chk",
|
||||
hook_id: Hook::FilesystemIfacePaths,
|
||||
replacement: hooked_readlinkat_chk as *mut _,
|
||||
store_orig: set_real_readlinkat_chk_ptr,
|
||||
},
|
||||
HookDesc {
|
||||
sym: c"readdir",
|
||||
hook_id: Hook::FilesystemIfacePaths,
|
||||
replacement: hooked_readdir as *mut _,
|
||||
store_orig: set_real_readdir_ptr,
|
||||
},
|
||||
HookDesc {
|
||||
sym: c"readdir64",
|
||||
hook_id: Hook::FilesystemIfacePaths,
|
||||
replacement: hooked_readdir64 as *mut _,
|
||||
store_orig: set_real_readdir64_ptr,
|
||||
},
|
||||
];
|
||||
|
||||
let mut filesystem_stubs = Vec::with_capacity(filesystem_table.len());
|
||||
if filesystem_requested && filesystem_error.is_none() {
|
||||
for desc in filesystem_table {
|
||||
match hook_libc_sym(desc.sym, desc.replacement, desc.store_orig) {
|
||||
Ok(stub) => filesystem_stubs.push(stub),
|
||||
Err(err) => {
|
||||
rollback_hooks(&mut filesystem_stubs);
|
||||
if !proc_open_requested {
|
||||
if let Some(stub) = shared_openat_stub.take() {
|
||||
rollback_hook(stub);
|
||||
}
|
||||
}
|
||||
filesystem_error = Some(err);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut installed_mask = hookmask & ZYGISK_HOOK_MASK & !Hook::FilesystemIfacePaths.bit();
|
||||
if filesystem_requested && filesystem_error.is_none() {
|
||||
installed_mask |= Hook::FilesystemIfacePaths.bit();
|
||||
}
|
||||
set_active_hookmask(installed_mask);
|
||||
Ok(HookInstallReport {
|
||||
requested_mask: hookmask & ZYGISK_HOOK_MASK,
|
||||
installed_mask,
|
||||
filesystem_error,
|
||||
})
|
||||
}
|
||||
|
||||
fn rollback_hook(stub: *mut c_void) {
|
||||
let rc = unsafe { shadowhook::unhook(stub) };
|
||||
if rc != 0 {
|
||||
error!("install_hooks rollback: shadowhook_unhook rc={rc}");
|
||||
}
|
||||
}
|
||||
|
||||
fn rollback_hooks(stubs: &mut Vec<*mut c_void>) {
|
||||
for stub in stubs.drain(..).rev() {
|
||||
rollback_hook(stub);
|
||||
}
|
||||
}
|
||||
|
||||
/// Install a single inline hook on a libc symbol and stash the
|
||||
|
|
@ -527,6 +730,7 @@ fn hook_libc_sym(
|
|||
));
|
||||
}
|
||||
if orig.is_null() {
|
||||
rollback_hook(stub);
|
||||
return Err(format!(
|
||||
"shadowhook returned null trampoline for libc.so!{}",
|
||||
sym.to_string_lossy()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue