feat(zygisk): add best-effort filesystem path hiding

This commit is contained in:
okhsunrog 2026-08-13 22:53:05 +03:00
parent e712e3b3ec
commit 02015d45e6
29 changed files with 1472 additions and 121 deletions

View file

@ -0,0 +1,9 @@
_2026-08-13_
## English
Added optional best-effort filesystem path hiding to the Zygisk backend
## Русский
Добавлено опциональное best-effort скрытие путей интерфейсов для бэкенда Zygisk

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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). */

View file

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

View file

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

View file

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

View file

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

View file

@ -39,6 +39,7 @@ class StorageConfigTest {
"zygisk_recvfrom",
"zygisk_recvfrom_chk",
"zygisk_setsockopt",
"filesystem_iface_paths",
),
ZygiskNativeHookEntries.map { it.hookName },
)

View file

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

View file

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

View file

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

View file

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