refactor(zygisk): clarify imports and hook flow

This commit is contained in:
okhsunrog 2026-08-11 22:43:13 +03:00
parent 61f64bce74
commit 652ed790d7
5 changed files with 149 additions and 153 deletions

View file

@ -11,8 +11,8 @@
//!
//! See the fork's commit message for the full rationale.
use std::env;
use std::path::PathBuf;
use std::{env, fs};
fn main() {
println!("cargo:rerun-if-changed=build.rs");
@ -24,9 +24,10 @@ fn main() {
// the Android cdylib; skip the whole native build step.
return;
}
if !target.starts_with("aarch64") {
panic!("vpnhide-zygisk currently only supports aarch64-linux-android (target={target})");
}
assert!(
target.starts_with("aarch64"),
"vpnhide-zygisk currently only supports aarch64-linux-android (target={target})"
);
// cargo-ndk sets ANDROID_NDK_HOME before invoking us.
let ndk = env::var("ANDROID_NDK_HOME")
@ -96,9 +97,9 @@ fn main() {
/// Typical layout: `<ndk>/toolchains/llvm/prebuilt/<host>/lib/clang/<ver>/lib/linux/…`.
fn find_ndk_builtins(ndk: &str) -> Option<PathBuf> {
let base = PathBuf::from(ndk).join("toolchains/llvm/prebuilt");
for host in std::fs::read_dir(&base).ok()?.flatten() {
for host in fs::read_dir(&base).ok()?.flatten() {
let clang_dir = host.path().join("lib/clang");
let Ok(versions) = std::fs::read_dir(&clang_dir) else {
let Ok(versions) = fs::read_dir(&clang_dir) else {
continue;
};
for v in versions.flatten() {

View file

@ -46,8 +46,7 @@ fn compact_lines(data: &mut [u8], mut hide: impl FnMut(&[u8]) -> bool) -> usize
let line_end = data[read_pos..]
.iter()
.position(|&b| b == b'\n')
.map(|p| read_pos + p + 1)
.unwrap_or(len);
.map_or(len, |position| read_pos + position + 1);
if !hide(&data[read_pos..line_end]) {
let line_len = line_end - read_pos;
@ -96,12 +95,11 @@ pub fn filter_ipv6_route_buf(data: &mut [u8]) -> usize {
/// drop lines whose interface name (trimmed, before the first ':') is a VPN
/// interface. The two header lines have no `name:` token and are kept.
pub fn filter_dev_buf(data: &mut [u8]) -> usize {
compact_lines(data, |line| match line.iter().position(|&b| b == b':') {
Some(colon) => {
compact_lines(data, |line| {
line.iter().position(|&b| b == b':').is_some_and(|colon| {
let name = trim_ascii_ws(&line[..colon]);
!name.is_empty() && is_vpn_iface_bytes(name)
}
None => false,
})
})
}
@ -215,7 +213,7 @@ fn parse_hex_u32(hex: &[u8]) -> Option<u32> {
b'a'..=b'f' => b - b'a' + 10,
_ => return None,
};
val = val.checked_shl(4)? | digit as u32;
val = val.checked_shl(4)? | u32::from(digit);
}
Some(val)
}
@ -341,10 +339,8 @@ pub fn filter_netlink_dump(data: &mut [u8], vpn_indices: &[u32]) -> usize {
vpn_indices.contains(&if_index)
} else if nlmsg_type == RTM_NEWROUTE && nlmsg_len >= NLMSG_HDRLEN + RTMSG_HDRLEN {
// Output interface is an RTA_OIF rtattr after struct rtmsg.
match route_oif(&data[read_pos..read_pos + nlmsg_len]) {
Some(oif) => vpn_indices.contains(&oif),
None => false,
}
route_oif(&data[read_pos..read_pos + nlmsg_len])
.is_some_and(|oif| vpn_indices.contains(&oif))
} else {
false
};
@ -374,6 +370,7 @@ pub fn filter_netlink_dump(data: &mut [u8], vpn_indices: &[u32]) -> usize {
#[cfg(test)]
mod tests {
use super::*;
use core::str;
#[test]
fn detects_tun0() {
@ -390,7 +387,7 @@ face |bytes packets errs drop\n lo: 100 1 0 0\n wlan0: 200
tun0: 300 3 0 0\n"
.to_vec();
let n = filter_dev_buf(&mut buf);
let out = core::str::from_utf8(&buf[..n]).unwrap();
let out = str::from_utf8(&buf[..n]).unwrap();
assert!(out.contains("Inter-|"), "header kept");
assert!(out.contains("face |"), "second header kept");
assert!(out.contains("lo:"), "lo kept");
@ -457,7 +454,7 @@ tun0: 300 3 0 0\n"
rmnet0\tFEFFFFFF\t00000000\n";
let mut buf = input.to_vec();
let new_len = filter_route_buf(&mut buf);
let result = core::str::from_utf8(&buf[..new_len]).unwrap();
let result = str::from_utf8(&buf[..new_len]).unwrap();
assert!(result.contains("Iface\t"));
assert!(result.contains("wlan0\t"));
assert!(result.contains("rmnet0\t"));
@ -479,7 +476,7 @@ tun0: 300 3 0 0\n"
let input = b"Iface\tDest\nwg0\t00000000\nwlan0\t00000000\n";
let mut buf = input.to_vec();
let new_len = filter_route_buf(&mut buf);
let result = core::str::from_utf8(&buf[..new_len]).unwrap();
let result = str::from_utf8(&buf[..new_len]).unwrap();
assert!(!result.contains("wg0"));
assert!(result.contains("wlan0"));
}

View file

@ -1,16 +1,16 @@
//! The actual libc hook functions.
//!
//! These are called INSTEAD OF the real libc symbols from any PLT we've
//! patched. Each hook calls the saved original function via a function
//! pointer stored in `statics` after `pltHookRegister` ran. We can't rely
//! on linkage-time names like `libc::ioctl` because those would resolve
//! back through our own PLT entry — which would either recurse infinitely
//! or, in a different library's process, not be hooked at all.
//! These replace the real libc symbols through shadowhook's inline trampolines.
//! Each hook calls the original function through the trampoline captured when
//! the inline hook was installed. We can't call linkage-time names such as
//! `libc::ioctl`, because they would resolve back through the hooked entry point
//! and recurse.
//!
//! ## Important safety notes
//!
//! * The `*const ()` function pointers used by Zygisk's PLT hook API are
//! variadic in C (`ioctl` is `int ioctl(int fd, unsigned long req, ...)`)
//! * The `*const ()` function pointers returned by shadowhook represent
//! functions that may be variadic in C (`ioctl` is
//! `int ioctl(int fd, unsigned long req, ...)`)
//! but Rust doesn't have first-class variadic functions. We work around
//! this by declaring the relevant `ioctl` signatures we care about
//! (`SIOCGIFNAME`, `SIOCGIFFLAGS`) as 3-argument forms and trusting the
@ -23,12 +23,17 @@
//! original. Panicking would abort the entire process.
use core::cell::{Cell, RefCell};
use core::ffi::{c_int, c_void};
use core::ffi::{CStr, c_int, c_void};
use core::sync::atomic::{AtomicPtr, AtomicU8, Ordering};
use core::{mem, ptr, slice};
use libc::{SIOCGIFCONF, SIOCGIFNAME, ifreq};
use crate::filter::is_vpn_iface_bytes;
use crate::filter::{
MAX_VPN_ADDRS, RTM_NEWADDR, RTM_NEWLINK, RTM_NEWROUTE, filter_dev_buf, filter_if_inet6_buf,
filter_ipv6_route_buf, filter_netlink_dump, filter_route_buf, filter_tcp4_buf, filter_tcp6_buf,
is_vpn_iface_bytes, is_vpn_iface_cstr,
};
/// `struct ifconf` from `<net/if.h>`. Not exported by the `libc` crate.
#[repr(C)]
@ -46,7 +51,6 @@ struct ifconf {
// causing errors like `ioctl(SIOCGIFFLAGS) for "tun0" failed in ifaddrs`
// and corrupting NFC/HCE payment flows).
thread_local! {
#[allow(clippy::missing_const_for_thread_local)]
static IN_GETIFADDRS: Cell<bool> = const { Cell::new(false) };
/// Reusable contiguous view for scatter/gather netlink recvmsg payloads.
@ -57,6 +61,17 @@ thread_local! {
static NETLINK_IOV_BUF: RefCell<Vec<u8>> = RefCell::new(Vec::with_capacity(65536));
}
/// Run one libc operation without letting the ioctl hook filter nested calls.
/// Preserve the previous value so nested guarded operations remain correct.
fn with_ioctl_passthrough<T>(operation: impl FnOnce() -> T) -> T {
IN_GETIFADDRS.with(|guard| {
let previous = guard.replace(true);
let result = operation();
guard.set(previous);
result
})
}
/// Returns true if `fd` is an `AF_NETLINK` socket.
///
/// Used by recv/recvmsg hooks to skip TCP/UDP/Unix sockets entirely —
@ -69,7 +84,7 @@ thread_local! {
/// chance accumulates to near-certain breakage.
fn is_netlink_fd(fd: c_int) -> bool {
let mut domain: c_int = 0;
let mut optlen = core::mem::size_of::<c_int>() as libc::socklen_t;
let mut optlen = mem::size_of::<c_int>() as libc::socklen_t;
let rc = unsafe {
libc::getsockopt(
fd,
@ -113,7 +128,7 @@ fn set_errno(val: c_int) {
// Saved originals
// ============================================================================
/// Declare a saved-original slot for one PLT-hooked libc function.
/// Declare a saved-original slot for one inline-hooked libc function.
///
/// Each hook needs the same four items to call through to the function it
/// replaced, so we generate them from one declaration instead of hand-copying
@ -121,8 +136,7 @@ fn set_errno(val: c_int) {
/// - `static $slot: AtomicPtr<c_void>` — the captured original pointer,
/// - `type $ty` — its function-pointer shape (doc comments carry through),
/// - `fn $getter() -> Option<$ty>` — None before install or if somehow null,
/// - `pub fn $setter(p: *const ())` — what `pltHookRegister` feeds the
/// original into.
/// - `pub fn $setter(p: *const ())` — stores the original trampoline.
///
/// The slot is read/written with relaxed atomics: install happens-before any
/// hook fires, so no stronger ordering is needed and no locks are taken.
@ -134,7 +148,7 @@ macro_rules! saved_original {
fn $getter:ident;
pub fn $setter:ident;
) => {
static $slot: AtomicPtr<c_void> = AtomicPtr::new(core::ptr::null_mut());
static $slot: AtomicPtr<c_void> = AtomicPtr::new(ptr::null_mut());
$(#[$tymeta])*
type $ty = $sig;
@ -147,11 +161,11 @@ macro_rules! saved_original {
} else {
// SAFETY: the slot only ever holds a valid pointer of this
// exact shape, stored once at install via the setter below.
Some(unsafe { core::mem::transmute::<*mut c_void, $ty>(raw) })
Some(unsafe { mem::transmute::<*mut c_void, $ty>(raw) })
}
}
/// Stash the original pointer returned by `pltHookRegister`.
/// Stash the original trampoline returned by shadowhook.
pub fn $setter(p: *const ()) {
$slot.store(p as *mut c_void, Ordering::Relaxed);
}
@ -230,14 +244,14 @@ fn kernel_needs_socket_bind_hiding() -> bool {
_ => {}
}
let mut uts = core::mem::MaybeUninit::<libc::utsname>::zeroed();
let mut uts = mem::MaybeUninit::<libc::utsname>::zeroed();
let hook = unsafe {
if libc::uname(uts.as_mut_ptr()) != 0 {
false
} else {
let uts = uts.assume_init();
let release =
core::slice::from_raw_parts(uts.release.as_ptr().cast::<u8>(), uts.release.len());
slice::from_raw_parts(uts.release.as_ptr().cast::<u8>(), uts.release.len());
let end = release
.iter()
.position(|&byte| byte == 0)
@ -278,16 +292,16 @@ fn copy_from_self(src: *const c_void, dst: &mut [u8]) -> bool {
let remote = libc::iovec {
// process_vm_readv's ABI uses mutable iovec fields even though the
// remote side is read-only.
iov_base: src as *mut c_void,
iov_base: src.cast_mut(),
iov_len: dst.len(),
};
let copied = unsafe {
libc::syscall(
libc::SYS_process_vm_readv,
libc::getpid(),
&local as *const libc::iovec,
ptr::from_ref(&local),
1usize,
&remote as *const libc::iovec,
ptr::from_ref(&remote),
1usize,
0usize,
)
@ -306,11 +320,8 @@ fn ifindex_is_vpn(ifindex: c_int) -> Option<bool> {
}
let mut name = [0u8; libc::IFNAMSIZ];
let result = IN_GETIFADDRS.with(|guard| {
let previous = guard.replace(true);
let result = unsafe { libc::if_indextoname(ifindex as u32, name.as_mut_ptr().cast()) };
guard.set(previous);
result
let result = with_ioctl_passthrough(|| unsafe {
libc::if_indextoname(ifindex as u32, name.as_mut_ptr().cast())
});
(!result.is_null()).then(|| is_vpn_iface_bytes(&name))
}
@ -328,7 +339,7 @@ fn deny_ifindex_bind(ifindex: c_int, resolved_is_vpn: Option<bool>) -> bool {
/// its native `EBADF`/`ENOTSOCK` ordering before we inspect the interface name.
fn is_socket_fd(fd: c_int) -> bool {
let mut socket_type: c_int = 0;
let mut len = core::mem::size_of::<c_int>() as libc::socklen_t;
let mut len = mem::size_of::<c_int>() as libc::socklen_t;
unsafe {
libc::getsockopt(
fd,
@ -415,13 +426,13 @@ pub unsafe extern "C" fn hooked_setsockopt(
}
if optname == SO_BINDTOIFINDEX {
if optlen < core::mem::size_of::<c_int>() as libc::socklen_t
if optlen < mem::size_of::<c_int>() as libc::socklen_t
|| optlen > c_int::MAX as libc::socklen_t
{
return unsafe { real(fd, level, optname, optval, optlen) };
}
let mut raw = [0u8; core::mem::size_of::<c_int>()];
let mut raw = [0u8; mem::size_of::<c_int>()];
if !copy_from_self(optval, &mut raw) {
return unsafe { real(fd, level, optname, optval, optlen) };
}
@ -439,7 +450,7 @@ pub unsafe extern "C" fn hooked_setsockopt(
level,
optname,
(&ifindex as *const c_int).cast(),
core::mem::size_of::<c_int>() as libc::socklen_t,
mem::size_of::<c_int>() as libc::socklen_t,
)
};
}
@ -501,7 +512,7 @@ pub unsafe extern "C" fn hooked_ioctl(
if ret == 0 && !arg.is_null() {
let req = unsafe { &*(arg as *const ifreq) };
let name_bytes = unsafe {
core::slice::from_raw_parts(req.ifr_name.as_ptr().cast::<u8>(), req.ifr_name.len())
slice::from_raw_parts(req.ifr_name.as_ptr().cast::<u8>(), req.ifr_name.len())
};
if is_vpn_iface_bytes(name_bytes) {
set_errno(libc::ENODEV);
@ -516,7 +527,7 @@ pub unsafe extern "C" fn hooked_ioctl(
if !arg.is_null() && is_siocgif(request) {
let req = unsafe { &*(arg as *const ifreq) };
let name_bytes = unsafe {
core::slice::from_raw_parts(req.ifr_name.as_ptr().cast::<u8>(), req.ifr_name.len())
slice::from_raw_parts(req.ifr_name.as_ptr().cast::<u8>(), req.ifr_name.len())
};
if is_vpn_iface_bytes(name_bytes) {
set_errno(libc::ENODEV);
@ -553,21 +564,21 @@ unsafe fn filter_ifconf(ifc: *mut ifconf) {
return;
}
let entry_size = core::mem::size_of::<ifreq>() as c_int;
let entry_size = mem::size_of::<ifreq>() as c_int;
let n = ifc.ifc_len / entry_size;
let mut dst = 0i32;
for i in 0..n {
let entry = unsafe { &*ifc.ifc_req.offset(i as isize) };
let name_bytes = unsafe {
core::slice::from_raw_parts(entry.ifr_name.as_ptr().cast::<u8>(), entry.ifr_name.len())
slice::from_raw_parts(entry.ifr_name.as_ptr().cast::<u8>(), entry.ifr_name.len())
};
if is_vpn_iface_bytes(name_bytes) {
continue;
}
if dst != i {
unsafe {
core::ptr::copy_nonoverlapping(
ptr::copy_nonoverlapping(
ifc.ifc_req.offset(i as isize),
ifc.ifc_req.offset(dst as isize),
1,
@ -581,7 +592,7 @@ unsafe fn filter_ifconf(ifc: *mut ifconf) {
// A caller can inspect its buffer past the shortened ifc_len. Erase
// only slots returned by the kernel and removed by compaction.
unsafe {
core::ptr::write_bytes(ifc.ifc_req.offset(dst as isize), 0, (n - dst) as usize);
ptr::write_bytes(ifc.ifc_req.offset(dst as isize), 0, (n - dst) as usize);
}
}
@ -688,9 +699,7 @@ pub unsafe extern "C" fn hooked_getifaddrs(ifap: *mut *mut libc::ifaddrs) -> c_i
// Set the thread-local guard so hooked_ioctl passes through while
// libc's real getifaddrs runs (it internally calls ioctl for each
// interface to get flags — we must not filter those).
IN_GETIFADDRS.with(|f| f.set(true));
let rc = unsafe { real(ifap) };
IN_GETIFADDRS.with(|f| f.set(false));
let rc = with_ioctl_passthrough(|| unsafe { real(ifap) });
if rc != 0 || ifap.is_null() {
return rc;
@ -708,8 +717,8 @@ pub unsafe extern "C" fn hooked_getifaddrs(ifap: *mut *mut libc::ifaddrs) -> c_i
let is_vpn = if name_ptr.is_null() {
false
} else {
let name = core::ffi::CStr::from_ptr(name_ptr);
crate::filter::is_vpn_iface_cstr(name)
let name = CStr::from_ptr(name_ptr);
is_vpn_iface_cstr(name)
};
if is_vpn {
*slot = (*entry).ifa_next;
@ -914,7 +923,7 @@ pub unsafe extern "C" fn hooked_openat(
};
if !pathname.is_null() {
let path = unsafe { core::ffi::CStr::from_ptr(pathname) };
let path = unsafe { CStr::from_ptr(pathname) };
let path_bytes = path.to_bytes();
let matched = if path_bytes.first() == Some(&b'/') {
@ -1033,8 +1042,6 @@ unsafe fn open_filtered_proc_net(
/// Dispatch to the right filter function based on the file type.
fn apply_filter(data: &mut [u8], kind: ProcNetFile) -> usize {
use crate::filter::*;
match kind {
ProcNetFile::Route => filter_route_buf(data),
ProcNetFile::Ipv6Route => filter_ipv6_route_buf(data),
@ -1066,16 +1073,13 @@ fn apply_filter(data: &mut [u8], kind: ProcNetFile) -> usize {
/// getifaddrs calls ioctl(SIOCGIFFLAGS) internally. Returns `false` if
/// the real getifaddrs() symbol couldn't be resolved or the call failed;
/// the closure is then never invoked.
unsafe fn walk_getifaddrs_vpn(mut f: impl FnMut(&core::ffi::CStr, &libc::ifaddrs)) -> bool {
unsafe fn walk_getifaddrs_vpn(mut f: impl FnMut(&CStr, &libc::ifaddrs)) -> bool {
let Some(real) = real_getifaddrs() else {
return false;
};
let mut ifap: *mut libc::ifaddrs = core::ptr::null_mut();
IN_GETIFADDRS.with(|flag| flag.set(true));
let rc = unsafe { real(&mut ifap) };
IN_GETIFADDRS.with(|flag| flag.set(false));
let mut ifap: *mut libc::ifaddrs = ptr::null_mut();
let rc = with_ioctl_passthrough(|| unsafe { real(&mut ifap) });
if rc != 0 || ifap.is_null() {
return false;
@ -1089,8 +1093,8 @@ unsafe fn walk_getifaddrs_vpn(mut f: impl FnMut(&core::ffi::CStr, &libc::ifaddrs
if entry.ifa_name.is_null() {
continue;
}
let name = unsafe { core::ffi::CStr::from_ptr(entry.ifa_name) };
if !crate::filter::is_vpn_iface_cstr(name) {
let name = unsafe { CStr::from_ptr(entry.ifa_name) };
if !is_vpn_iface_cstr(name) {
continue;
}
@ -1105,13 +1109,11 @@ unsafe fn walk_getifaddrs_vpn(mut f: impl FnMut(&core::ffi::CStr, &libc::ifaddrs
/// real (unhooked) `getifaddrs`. Sets `IN_GETIFADDRS` guard so our
/// ioctl hook doesn't interfere with libc's internal SIOCGIFFLAGS calls.
fn collect_vpn_addrs() -> (
[u32; crate::filter::MAX_VPN_ADDRS],
[u32; MAX_VPN_ADDRS],
usize,
[[u32; 4]; crate::filter::MAX_VPN_ADDRS],
[[u32; 4]; MAX_VPN_ADDRS],
usize,
) {
use crate::filter::MAX_VPN_ADDRS;
let mut addrs4 = [0u32; MAX_VPN_ADDRS];
let mut addrs6 = [[0u32; 4]; MAX_VPN_ADDRS];
let mut n4 = 0usize;
@ -1221,7 +1223,7 @@ pub unsafe extern "C" fn hooked_recvmsg(fd: c_int, msg: *mut libc::msghdr, flags
hdr.msg_iovlen,
in_iovecs,
&mut scratch,
|data| crate::filter::filter_netlink_dump(data, &indices[..n]),
|data| filter_netlink_dump(data, &indices[..n]),
)
}) else {
return ret;
@ -1286,7 +1288,7 @@ unsafe fn rewrite_iovec_payload(
if entry.iov_base.is_null() {
return None;
}
let bytes = unsafe { core::slice::from_raw_parts(entry.iov_base.cast::<u8>(), take) };
let bytes = unsafe { slice::from_raw_parts(entry.iov_base.cast::<u8>(), take) };
scratch.extend_from_slice(bytes);
remaining -= take;
}
@ -1306,7 +1308,7 @@ unsafe fn rewrite_iovec_payload(
continue;
}
unsafe {
core::ptr::copy_nonoverlapping(
ptr::copy_nonoverlapping(
scratch.as_ptr().add(copied),
entry.iov_base.cast::<u8>(),
take,
@ -1349,16 +1351,13 @@ unsafe fn maybe_filter_netlink_buf(fd: c_int, buf: *mut u8, ret: isize) -> isize
return ret;
}
let data = unsafe { core::slice::from_raw_parts_mut(buf, ret as usize) };
let data = unsafe { slice::from_raw_parts_mut(buf, ret as usize) };
// Quick check: first message type must be one we filter. Route dumps
// (RTM_GETROUTE) come back as RTM_NEWROUTE and must not be skipped —
// that gap was the issue #86 `if<N>` leak.
let nlmsg_type = u16::from_ne_bytes([data[4], data[5]]);
if nlmsg_type != crate::filter::RTM_NEWADDR
&& nlmsg_type != crate::filter::RTM_NEWLINK
&& nlmsg_type != crate::filter::RTM_NEWROUTE
{
if nlmsg_type != RTM_NEWADDR && nlmsg_type != RTM_NEWLINK && nlmsg_type != RTM_NEWROUTE {
return ret;
}
@ -1367,7 +1366,7 @@ unsafe fn maybe_filter_netlink_buf(fd: c_int, buf: *mut u8, ret: isize) -> isize
return ret;
}
crate::filter::filter_netlink_dump(data, &indices[..n]) as isize
filter_netlink_dump(data, &indices[..n]) as isize
}
// ============================================================================
@ -1505,9 +1504,7 @@ pub unsafe extern "C" fn hooked_recvfrom_chk(
/// Collect interface indices of VPN interfaces. Uses real_getifaddrs
/// (with IN_GETIFADDRS guard) and `if_nametoindex` (which calls
/// ioctl(SIOCGIFINDEX) — passed through by our ioctl hook).
fn collect_vpn_iface_indices() -> ([u32; crate::filter::MAX_VPN_ADDRS], usize) {
use crate::filter::MAX_VPN_ADDRS;
fn collect_vpn_iface_indices() -> ([u32; MAX_VPN_ADDRS], usize) {
let mut indices = [0u32; MAX_VPN_ADDRS];
let mut n = 0usize;
@ -1520,13 +1517,7 @@ fn collect_vpn_iface_indices() -> ([u32; crate::filter::MAX_VPN_ADDRS], usize) {
// now blocks for VPN names. Run it under the IN_GETIFADDRS guard so
// it passes through to the real index — otherwise this filter loses
// the VPN indices and the netlink route dump (#86) stops filtering.
let idx = IN_GETIFADDRS.with(|f| {
let prev = f.get();
f.set(true);
let i = libc::if_nametoindex(entry.ifa_name);
f.set(prev);
i
});
let idx = with_ioctl_passthrough(|| libc::if_nametoindex(entry.ifa_name));
if idx == 0 || indices[..n].contains(&idx) {
return;
}

View file

@ -78,17 +78,25 @@ mod generated;
mod hooks;
mod shadowhook;
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
use core::ffi::{CStr, c_void};
use core::ptr;
use std::fs;
use std::io::{self, Read};
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
use std::path::Path;
use std::process;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::{Once, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
use log::{debug, error, info};
use android_logger::Config;
use log::{LevelFilter, debug, error, info};
use vpnhide_protocol as protocol;
use vpnhide_protocol::hook_ids::{Hook, ZYGISK_HOOK_MASK};
use zygisk_api::ZygiskModule;
use zygisk_api::api::ZygiskApi;
use zygisk_api::api::v2::{AppSpecializeArgs, V2, ZygiskOption};
use zygisk_api::jni::JNIEnv;
use zygisk_api::jni::{JNIEnv, objects::JString};
use zygisk_api::{ZygiskModule, register_companion, register_module};
use crate::hooks::{
hooked_getifaddrs, hooked_ioctl, hooked_openat, hooked_recv, hooked_recvfrom,
@ -118,9 +126,9 @@ fn init_logger() {
static INIT: Once = Once::new();
INIT.call_once(|| {
android_logger::init_once(
android_logger::Config::default()
Config::default()
.with_tag(LOG_TAG)
.with_max_level(log::LevelFilter::Trace),
.with_max_level(LevelFilter::Trace),
);
});
}
@ -134,9 +142,9 @@ fn init_logger() {
/// Same principle as HookLog.e on the Kotlin side.
fn set_log_level(enabled: bool) {
let level = if enabled {
log::LevelFilter::Trace
LevelFilter::Trace
} else {
log::LevelFilter::Error
LevelFilter::Error
};
log::set_max_level(level);
}
@ -180,23 +188,26 @@ static CACHED_CONFIG: OnceLock<ZygiskConfig> = OnceLock::new();
/// privileges, bypassing the SELinux restrictions that block direct file
/// access on Magisk. A missing/unreadable/invalid file fails closed: no
/// targets, logging off.
fn load_config_from_dir_fd(dir_fd: std::os::fd::RawFd) -> ZygiskConfig {
use std::io::Read;
use std::os::fd::FromRawFd;
fn load_config_from_dir_fd(dir_fd: RawFd) -> ZygiskConfig {
let empty = ZygiskConfig {
targets: Vec::new(),
debug: false,
};
let filename = std::ffi::CString::new(TARGETS_FILENAME).unwrap();
let fd = unsafe { libc::openat(dir_fd, filename.as_ptr(), libc::O_RDONLY | libc::O_CLOEXEC) };
let fd = unsafe {
libc::openat(
dir_fd,
c"targets.txt".as_ptr(),
libc::O_RDONLY | libc::O_CLOEXEC,
)
};
if fd < 0 {
log::warn!(
"can't open {TARGETS_FILENAME} via module dir fd: {}",
std::io::Error::last_os_error()
io::Error::last_os_error()
);
return empty;
}
let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
let mut file = unsafe { fs::File::from_raw_fd(fd) };
let mut content = Vec::new();
if let Err(e) = file.read_to_end(&mut content) {
log::warn!("can't read {TARGETS_FILENAME}: {e}");
@ -282,7 +293,12 @@ impl ZygiskModule for VpnHide {
// UID is the targeting key (protocol §4.3). args.uid already holds the
// UID this child will become, and one UID covers every process of a
// multi-process app — so no package/sub-process name matching needed.
let uid = *args.uid as u32;
let Ok(uid) = u32::try_from(*args.uid) else {
self.target_hookmask.store(0, Ordering::Relaxed);
self.report_status.store(false, Ordering::Relaxed);
mark_cleanup(&mut api);
return;
};
// nice_name is still read, but only to recognise the VPN Hide app
// itself for the status heartbeat — an identity check, not targeting.
let package = read_jstring(&env, args.nice_name);
@ -329,14 +345,14 @@ impl ZygiskModule for VpnHide {
}
fn write_status_file() {
let boot_id = match std::fs::read_to_string("/proc/sys/kernel/random/boot_id") {
let boot_id = match fs::read_to_string("/proc/sys/kernel/random/boot_id") {
Ok(v) => v.trim().to_string(),
Err(err) => {
error!("failed to read boot_id: {err}");
return;
}
};
let timestamp = match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
let timestamp = match SystemTime::now().duration_since(UNIX_EPOCH) {
Ok(v) => v.as_secs(),
Err(err) => {
error!("failed to get timestamp: {err}");
@ -347,16 +363,16 @@ fn write_status_file() {
"version={}\nboot_id={}\npid={}\ntimestamp={}\n",
env!("CARGO_PKG_VERSION"),
boot_id,
std::process::id(),
process::id(),
timestamp,
);
if let Some(parent) = std::path::Path::new(APP_STATUS_FILE).parent() {
if let Err(err) = std::fs::create_dir_all(parent) {
if let Some(parent) = Path::new(APP_STATUS_FILE).parent() {
if let Err(err) = fs::create_dir_all(parent) {
error!("failed to create status dir {}: {err}", parent.display());
return;
}
}
match std::fs::write(APP_STATUS_FILE, content) {
match fs::write(APP_STATUS_FILE, content) {
Ok(()) => info!("wrote zygisk status heartbeat to {APP_STATUS_FILE}"),
Err(err) => error!("failed to write status heartbeat: {err}"),
}
@ -368,6 +384,13 @@ fn mark_cleanup(api: &mut ZygiskApi<'_, V2>) {
api.set_option(ZygiskOption::DlCloseModuleLibrary);
}
struct HookDesc {
sym: &'static CStr,
hook_id: Hook,
replacement: *mut c_void,
store_orig: fn(*const ()),
}
/// Install selected inline hooks on `libc.so` via ByteDance shadowhook.
///
/// * `ioctl` — catches `SIOCGIFNAME` / `SIOCGIFFLAGS` interface
@ -404,12 +427,6 @@ 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).
struct HookDesc {
sym: &'static core::ffi::CStr,
hook_id: Hook,
replacement: *mut core::ffi::c_void,
store_orig: fn(*const ()),
}
let table = [
HookDesc {
sym: c"ioctl",
@ -461,7 +478,7 @@ fn install_hooks(hookmask: u32) -> Result<(), String> {
},
];
let mut installed: Vec<*mut core::ffi::c_void> = Vec::with_capacity(table.len());
let mut installed: Vec<*mut c_void> = Vec::with_capacity(table.len());
for desc in table {
if !zygisk_hook_enabled(hookmask, desc.hook_id) {
continue;
@ -495,11 +512,11 @@ fn install_hooks(hookmask: u32) -> Result<(), String> {
/// pointer on success — caller keeps it for the partial-install
/// rollback in `install_hooks`.
fn hook_libc_sym(
sym: &core::ffi::CStr,
new_fn: *mut core::ffi::c_void,
sym: &CStr,
new_fn: *mut c_void,
store_orig: fn(*const ()),
) -> Result<*mut core::ffi::c_void, String> {
let mut orig: *mut core::ffi::c_void = core::ptr::null_mut();
) -> Result<*mut c_void, String> {
let mut orig: *mut c_void = ptr::null_mut();
// SAFETY: `new_fn` has an ABI-compatible signature with the target
// libc symbol; `&mut orig` is a valid writable pointer.
let stub = unsafe { shadowhook::hook_sym(c"libc.so", sym, new_fn, &mut orig) };
@ -549,8 +566,6 @@ fn scrub_shadowhook_maps() {
// not in the filter list today, but a future addition would silently
// break this function.
let maps = {
use std::io::Read;
use std::os::fd::FromRawFd;
let fd = unsafe {
libc::open(
c"/proc/self/maps".as_ptr(),
@ -561,7 +576,7 @@ fn scrub_shadowhook_maps() {
log::warn!("scrub_shadowhook_maps: can't open /proc/self/maps");
return;
}
let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
let mut file = unsafe { fs::File::from_raw_fd(fd) };
let mut buf = String::new();
if file.read_to_string(&mut buf).is_err() {
log::warn!("scrub_shadowhook_maps: can't read /proc/self/maps");
@ -616,7 +631,7 @@ fn scrub_shadowhook_maps() {
} else {
log::warn!(
"prctl(PR_SET_VMA_ANON_NAME) failed for {start_hex}-{end_hex}: errno={}",
std::io::Error::last_os_error()
io::Error::last_os_error()
);
}
}
@ -631,15 +646,10 @@ fn scrub_shadowhook_maps() {
/// sub-process name handling (the old package-name path did).
#[inline(never)]
fn target_hookmask(uid: u32) -> u32 {
match CACHED_CONFIG.get() {
Some(cfg) => cfg
.targets
.iter()
.find(|target| target.uid == uid)
.map(|target| target.hookmask)
.unwrap_or(0),
None => 0,
}
CACHED_CONFIG
.get()
.and_then(|cfg| cfg.targets.iter().find(|target| target.uid == uid))
.map_or(0, |target| target.hookmask)
}
fn zygisk_hook_enabled(hookmask: u32, hook: Hook) -> bool {
@ -648,10 +658,7 @@ fn zygisk_hook_enabled(hookmask: u32, hook: Hook) -> bool {
/// Decode a `JString` (as stored in `AppSpecializeArgs::nice_name`) into
/// an owned Rust `String`. Returns None on any failure.
fn read_jstring<'a>(
env: &JNIEnv<'a>,
jstr: &zygisk_api::jni::objects::JString<'a>,
) -> Option<String> {
fn read_jstring<'a>(env: &JNIEnv<'a>, jstr: &JString<'a>) -> Option<String> {
if jstr.is_null() {
return None;
}
@ -659,11 +666,11 @@ fn read_jstring<'a>(
env_clone
.get_string(jstr)
.ok()
.and_then(|s| s.to_str().ok().map(|s| s.to_string()))
.and_then(|s| s.to_str().ok().map(str::to_owned))
}
zygisk_api::register_module!(VpnHide);
register_module!(VpnHide);
// Empty companion — declared so we don't have to recompile the .so if we
// ever want to add a root-privileged helper later.
zygisk_api::register_companion!(|_| ());
register_companion!(|_| ());

View file

@ -6,7 +6,7 @@
//! is linked as a static archive (`libshadowhook.a`) built for
//! `aarch64-linux-android`; see `build.rs`.
use core::ffi::{c_char, c_int, c_void};
use core::ffi::{CStr, c_char, c_int, c_void};
use std::sync::OnceLock;
// `SHADOWHOOK_MODE_UNIQUE` from shadowhook.h. We never use shared mode, so a
@ -54,8 +54,8 @@ pub fn init_once() -> Result<(), c_int> {
/// `new_fn` must be a valid function pointer with a signature ABI-compatible
/// with the real target symbol. `out_orig` must be a valid writable pointer.
pub unsafe fn hook_sym(
lib: &core::ffi::CStr,
sym: &core::ffi::CStr,
lib: &CStr,
sym: &CStr,
new_fn: *mut c_void,
out_orig: *mut *mut c_void,
) -> *mut c_void {