From 887e61286a49f78a9c2fdb431a02205e9f0e01c5 Mon Sep 17 00:00:00 2001 From: okhsunrog Date: Sat, 15 Aug 2026 19:40:53 +0300 Subject: [PATCH] feat(zygisk): hide RTM_GETRULE policy rules and getdents64 dir listings Closes the two detection vectors that a Zygisk-only device (no kernel backend) previously leaked, so the native tile owns them instead of surfacing them as uncovered. - filter::rule_hides_vpn parses RTM_NEWRULE in the same recv netlink filter as GETLINK/GETROUTE, mirroring the app probe predicate exactly: drop a rule that names a VPN iface (FRA_OIFNAME/IIFNAME) or steers this uid into a tun table - filesystem: hook getdents64 so raw directory enumeration of /sys/class/net (and .../net, /proc/sys/net/.../{conf,neigh}) strips the tun entry, not just libc readdir; installed best-effort so a missing symbol can't disable the whole filesystem feature - NativeChecks: netlink_getrule is now owned by the zygisk recv hooks --- ...lesystem-interface-hiding-now-also-659c.md | 9 + ...ygisk-now-hides-vpn-routing-policy-59ba.md | 9 + docs/detection-vectors.md | 11 +- .../dev/okhsunrog/vpnhide/NativeChecks.kt | 15 +- .../okhsunrog/vpnhide/DiagnosticReportTest.kt | 9 +- .../dev/okhsunrog/vpnhide/LayerStatusTest.kt | 13 +- zygisk/src/filesystem.rs | 127 +++++++++++ zygisk/src/filter.rs | 210 +++++++++++++++++- zygisk/src/hooks.rs | 20 +- zygisk/src/lib.rs | 31 ++- 10 files changed, 409 insertions(+), 45 deletions(-) create mode 100644 changelog.d/added-zygisk-filesystem-interface-hiding-now-also-659c.md create mode 100644 changelog.d/added-zygisk-now-hides-vpn-routing-policy-59ba.md diff --git a/changelog.d/added-zygisk-filesystem-interface-hiding-now-also-659c.md b/changelog.d/added-zygisk-filesystem-interface-hiding-now-also-659c.md new file mode 100644 index 00000000..aa46eb9e --- /dev/null +++ b/changelog.d/added-zygisk-filesystem-interface-hiding-now-also-659c.md @@ -0,0 +1,9 @@ +_2026-08-15_ + +## English + +Zygisk filesystem interface hiding now also filters raw getdents64 directory enumeration (e.g. of /sys/class/net), not just libc readdir, closing that listing path for native and Rust callers. + +## Русский + +Скрытие сетевых интерфейсов Zygisk на уровне файловой системы теперь фильтрует и перечисление каталогов через getdents64 (например, /sys/class/net), а не только через readdir из libc — это закрывает путь перечисления для нативных и Rust-приложений. diff --git a/changelog.d/added-zygisk-now-hides-vpn-routing-policy-59ba.md b/changelog.d/added-zygisk-now-hides-vpn-routing-policy-59ba.md new file mode 100644 index 00000000..65a4043d --- /dev/null +++ b/changelog.d/added-zygisk-now-hides-vpn-routing-policy-59ba.md @@ -0,0 +1,9 @@ +_2026-08-15_ + +## English + +Zygisk now hides VPN routing policy rules (ip rule / RTM_GETRULE) from target apps — a detection vector that previously only the kernel backends (.ko / KPM) covered, so Zygisk-only devices leaked it. + +## Русский + +Zygisk теперь скрывает правила маршрутизации VPN (ip rule / RTM_GETRULE) от целевых приложений — этот вектор раньше закрывали только модули уровня ядра (.ko / KPM), поэтому на устройствах только с Zygisk он утекал. diff --git a/docs/detection-vectors.md b/docs/detection-vectors.md index 27ff34ae..206d2c0c 100644 --- a/docs/detection-vectors.md +++ b/docs/detection-vectors.md @@ -71,9 +71,10 @@ Key consequences: any arm64 kernel, but only catches libc-routed calls (a raw `svc #0` slips past) and runs in-process, visible to aggressive anti-tamper. The small deltas between backends — e.g. Zygisk also filters - `/proc/net/{if_inet6,tcp,tcp6}`, kmod/KPM also handle `RTM_GETRULE` and the - server host-route — are noted in the matrix; neither delta is a reason to - stack native backends. + `/proc/net/{if_inet6,tcp,tcp6}`, kmod/KPM also handle the server host-route — + are noted in the matrix; neither delta is a reason to stack native backends. + (`RTM_GETRULE` used to be a kernel-only vector; Zygisk now parses it in the + same recv netlink filter as `RTM_GETLINK`/`RTM_GETROUTE`.) - So a complete install is **two components**: exactly one native backend (kmod, KPM, or Zygisk) **plus** lsposed — which covers both the Java network vectors and package visibility — with SELinux as an unreliable platform backstop @@ -118,7 +119,7 @@ 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//*` | 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 | +| `/sys/class/net//*` | lookup/stat/open/readlink/readdir reveal iface nodes | ⚠️ optional reboot-gated VFS hooks | ⚠️ optional reboot-gated VFS hooks | ⚠️ optional best-effort libc hooks (incl. `readdir`/`getdents64` listing filter) | — | 🔒 usually denied | | `/proc/sys/net/{ipv4,ipv6}/{conf,neigh}/` | 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 @@ -197,7 +198,7 @@ subcase is therefore `.ko`-only for now and remains a known KPM gap. | `/proc/net/ipv6_route` | text, iface last field | ✅ `ipv6_route_seq_show` | ✅ `ipv6_route_seq_show` | ✅ | — | 🔒 | | netlink `RTM_GETROUTE` **dump** | `RTA_OIF` index per route | ✅ `fib_dump_info` / `rt6_fill_node` | ✅ `fib_dump_info` / `rt6_fill_node` | ✅ `RTM_NEWROUTE` filter (issue #86) | — | | | netlink `RTM_GETROUTE` **single** (`ip route get`) | one `rt_fill_info` reply | ⚠️ intentionally unhooked (see ROADMAP) | ⚠️ intentionally unhooked | ⚠️ not filtered | — | | -| netlink `RTM_GETRULE` (policy rules) | per-UID lookup tables | ✅ `fib_nl_fill_rule` | ✅ `fib_nl_fill_rule` | — | — | | +| netlink `RTM_GETRULE` (policy rules) | per-UID lookup tables | ✅ `fib_nl_fill_rule` | ✅ `fib_nl_fill_rule` | ✅ `rule_hides_vpn` (recv filter) | — | | | host-route to the VPN **server** | `/32`·`/128` to a public IP via a *physical* iface | ✅ `is_public_host_route_via_physical` | ✅ `kpm_is_public_host_route{4,6}` | — | — | | | `LinkProperties.getRoutes()` (Java) | framework route list | — | — | — | ✅ filter `mRoutes` | | diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/NativeChecks.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/NativeChecks.kt index d1bb22c0..dd7276ba 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/NativeChecks.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/NativeChecks.kt @@ -78,11 +78,20 @@ internal val NATIVE_CHECKS: List = ), ), NativeCheckSpec( - // Kernel-only vector: no zygisk hook parses RTM_NEWRULE. The - // fib_nl_fill_rule kernel hook trims the per-UID tun policy rules. + // Kernel backends trim the per-UID tun policy rules via fib_nl_fill_rule; + // zygisk parses RTM_NEWRULE in the same recv netlink filter as GETLINK/ + // GETROUTE, dropping rules that name a VPN iface or steer this uid into a + // tun table (see filter::rule_hides_vpn). id = "netlink_getrule", labelRes = R.string.check_netlink_getrule, - expectedHooks = setOf(HookIds.Hook.FIB_NL_FILL_RULE), + expectedHooks = + setOf( + HookIds.Hook.FIB_NL_FILL_RULE, + HookIds.Hook.ZYGISK_RECVMSG, + HookIds.Hook.ZYGISK_RECV, + HookIds.Hook.ZYGISK_RECVFROM, + HookIds.Hook.ZYGISK_RECVFROM_CHK, + ), ), NativeCheckSpec( id = "proc_route", diff --git a/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/DiagnosticReportTest.kt b/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/DiagnosticReportTest.kt index 7d867802..f446329c 100644 --- a/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/DiagnosticReportTest.kt +++ b/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/DiagnosticReportTest.kt @@ -65,10 +65,11 @@ class DiagnosticReportTest { // ── unowned leaks are surfaced separately, never against the verdict ──── @Test - fun `a kernel-only leak under zygisk is unowned, not a verdict leak`() { - // netlink_getrule (fib_nl_fill_rule) has no zygisk hook → out of scope for - // the zygisk tile, so the verdict stays Ok and the leak is counted as unowned. - val r = report(results = CheckResults(native = nativeResults("netlink_getrule" to CheckOutcome.Leak))) + fun `a not-owned leak under zygisk is unowned, not a verdict leak`() { + // sys_class_net is covered only by the optional FILESYSTEM_IFACE_PATHS hook, + // which this report is built without installing → out of scope for the zygisk + // tile, so the verdict stays Ok and the leak is counted as unowned. + val r = report(results = CheckResults(native = nativeResults("sys_class_net" to CheckOutcome.Leak))) assertEquals(Verdict.Ok, r.nativeVerdict) assertEquals(1, r.native.unownedLeaks) } diff --git a/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/LayerStatusTest.kt b/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/LayerStatusTest.kt index af76f308..edb36174 100644 --- a/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/LayerStatusTest.kt +++ b/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/LayerStatusTest.kt @@ -67,7 +67,8 @@ class LayerStatusTest { ) val layer = summarizeNativeLayer(kmod(installed(active = true)), outcomes) assertEquals(LayerStatus.Active(hidden = 2, leaks = 0), layer) - // …but it is still surfaced, via the unowned-leak count (the hero warning). + // …but it is still surfaced, via the unowned-leak count (shown neutrally in + // the per-check breakdown — it never raises a dashboard warning). assertEquals(1, unownedNativeLeaks(kmod(installed(active = true)), outcomes)) } @@ -129,14 +130,14 @@ class LayerStatusTest { @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; - // netlink_getrule (fib_nl_fill_rule, kernel-only — no zygisk parses rule - // dumps) is not. That kernel-only leak is out of scope for the zygisk tile - // and shows up as an unowned leak instead. (Note: ioctl_flags would NOT - // work here — it is zygisk-owned too, via zygisk_ioctl.) + // sys_class_net (only the optional FILESYSTEM_IFACE_PATHS hook, not installed + // here) is not. That not-owned leak is out of scope for the zygisk tile and + // shows up as an unowned leak instead. (Note: ioctl_flags would NOT work here + // — it is zygisk-owned too, via zygisk_ioctl.) val outcomes = mapOf( "proc_dev" to CheckOutcome.Leak, - "netlink_getrule" to CheckOutcome.Leak, + "sys_class_net" to CheckOutcome.Leak, ) assertEquals( LayerStatus.Active(hidden = 0, leaks = 1), diff --git a/zygisk/src/filesystem.rs b/zygisk/src/filesystem.rs index 504a66f2..08cded29 100644 --- a/zygisk/src/filesystem.rs +++ b/zygisk/src/filesystem.rs @@ -507,6 +507,13 @@ saved_original!( set_real_readdir64_ptr, unsafe extern "C" fn(*mut libc::DIR) -> *mut libc::dirent64 ); +saved_original!( + Getdents64Fn, + REAL_GETDENTS64, + real_getdents64, + set_real_getdents64_ptr, + unsafe extern "C" fn(c_int, *mut c_void, usize) -> libc::ssize_t +); fn denied() -> c_int { set_errno(libc::ENOENT); @@ -707,6 +714,84 @@ pub(crate) unsafe extern "C" fn hooked_readdir64(dir: *mut libc::DIR) -> *mut li } } +// Offsets within a packed `struct linux_dirent64`: d_ino(8) + d_off(8) then +// d_reclen(u16) at 16 and the NUL-terminated d_name at 19. Records are +// variable-length (d_reclen), so the buffer is walked by d_reclen, never by a +// fixed struct stride. +const DENTS64_RECLEN_OFF: usize = 16; +const DENTS64_NAME_OFF: usize = 19; + +/// Whether a single packed `linux_dirent64` record (`rec` = one whole record, +/// `d_reclen` bytes) names a VPN interface. +fn dent64_name_is_vpn(rec: &[u8]) -> bool { + let Some(name) = rec.get(DENTS64_NAME_OFF..) else { + return false; + }; + let end = name + .iter() + .position(|byte| *byte == 0) + .unwrap_or(name.len()); + end != 0 && is_vpn_iface_bytes(&name[..end]) +} + +/// Drop VPN-interface entries from a raw `getdents64` buffer, compacting the +/// surviving records toward the front in place. Returns the new byte length. +/// A malformed record (bad `d_reclen`) stops parsing and the remainder is kept +/// verbatim, mirroring the netlink filter's conservative tail handling. +fn compact_dents64(buf: &mut [u8]) -> usize { + let len = buf.len(); + let mut read_pos = 0usize; + let mut write_pos = 0usize; + + while read_pos + DENTS64_NAME_OFF <= len { + let reclen = u16::from_ne_bytes([ + buf[read_pos + DENTS64_RECLEN_OFF], + buf[read_pos + DENTS64_RECLEN_OFF + 1], + ]) as usize; + if reclen < DENTS64_NAME_OFF || read_pos + reclen > len { + break; + } + if !dent64_name_is_vpn(&buf[read_pos..read_pos + reclen]) { + if write_pos != read_pos { + buf.copy_within(read_pos..read_pos + reclen, write_pos); + } + write_pos += reclen; + } + read_pos += reclen; + } + + if read_pos < len { + let tail = len - read_pos; + if write_pos != read_pos { + buf.copy_within(read_pos..len, write_pos); + } + write_pos += tail; + } + write_pos +} + +/// `getdents64` is the raw directory-enumeration syscall wrapper `readdir`/ +/// `readdir64` (and Rust/Go/native readers) sit on. Hooking it — not just the +/// higher-level `readdir*` — closes the `/sys/class/net` (and `.../net`, +/// `/proc/sys/net/.../{conf,neigh}`) listing for callers that read dirents +/// directly. Still best-effort: an inlined `svc #0` getdents64 bypasses it. +pub(crate) unsafe extern "C" fn hooked_getdents64( + fd: c_int, + dirp: *mut c_void, + count: usize, +) -> libc::ssize_t { + let Some(real) = real_getdents64() else { + set_errno(libc::EFAULT); + return -1; + }; + let n = unsafe { real(fd, dirp, count) }; + if n <= 0 || dirp.is_null() || !listing_fd(fd) { + return n; + } + let buf = unsafe { slice::from_raw_parts_mut(dirp.cast::(), n as usize) }; + compact_dents64(buf) as libc::ssize_t +} + #[cfg(test)] mod tests { use super::*; @@ -752,4 +837,46 @@ mod tests { let path = normalize_absolute(b"/../../sys/class/net/wlan0").unwrap(); assert_eq!(path.as_bytes(), b"/sys/class/net/wlan0"); } + + /// Build one packed `linux_dirent64` record for `name` (8-byte aligned, as + /// the kernel emits), with `d_reclen` filled in and the rest zeroed. + fn make_dent64(name: &[u8]) -> Vec { + let reclen = (DENTS64_NAME_OFF + name.len() + 1 + 7) & !7; // +1 NUL, align to 8 + let mut rec = vec![0u8; reclen]; + rec[DENTS64_RECLEN_OFF..DENTS64_RECLEN_OFF + 2] + .copy_from_slice(&(reclen as u16).to_ne_bytes()); + rec[DENTS64_NAME_OFF..DENTS64_NAME_OFF + name.len()].copy_from_slice(name); + rec + } + + #[test] + fn compact_dents64_drops_vpn_entries() { + let mut buf = Vec::new(); + buf.extend(make_dent64(b"lo")); + buf.extend(make_dent64(b"tun0")); + buf.extend(make_dent64(b"wlan0")); + let expect = make_dent64(b"lo").len() + make_dent64(b"wlan0").len(); + let n = compact_dents64(&mut buf); + assert_eq!(n, expect); + assert!( + buf[DENTS64_NAME_OFF..].starts_with(b"lo\0"), + "lo kept first" + ); + assert!(!buf[..n].windows(4).any(|w| w == b"tun0"), "tun0 removed"); + } + + #[test] + fn compact_dents64_keeps_all_when_no_vpn() { + let mut buf = Vec::new(); + buf.extend(make_dent64(b"lo")); + buf.extend(make_dent64(b"wlan0")); + let orig = buf.len(); + assert_eq!(compact_dents64(&mut buf), orig); + } + + #[test] + fn compact_dents64_removes_all_vpn() { + let mut buf = make_dent64(b"wg0"); + assert_eq!(compact_dents64(&mut buf), 0); + } } diff --git a/zygisk/src/filter.rs b/zygisk/src/filter.rs index 96878cd2..7d1acedb 100644 --- a/zygisk/src/filter.rs +++ b/zygisk/src/filter.rs @@ -248,6 +248,20 @@ const RTA_ALIGNTO: usize = 4; /// `rtattr` type carrying the output interface index (`RTA_OIF`). const RTA_OIF: u16 = 4; +/// `RTM_NEWRULE` — the reply the kernel emits for an `RTM_GETRULE` policy-rule +/// dump. `struct fib_rule_hdr` shares `struct rtmsg`'s 12-byte layout, so +/// [`RTMSG_HDRLEN`] doubles as the rule header length. +pub(crate) const RTM_NEWRULE: u16 = 32; +// fib_rule attribute (`FRA_*`) types and standard table ids +// (linux/fib_rules.h, linux/rtnetlink.h). +const FRA_IIFNAME: u16 = 3; +const FRA_TABLE: u16 = 15; +const FRA_OIFNAME: u16 = 17; +const FRA_UID_RANGE: u16 = 20; +const RT_TABLE_DEFAULT: u32 = 253; +const RT_TABLE_MAIN: u32 = 254; +const RT_TABLE_LOCAL: u32 = 255; + const fn nlmsg_align(len: usize) -> usize { (len + NLMSG_ALIGNTO - 1) & !(NLMSG_ALIGNTO - 1) } @@ -282,6 +296,71 @@ fn route_oif(msg: &[u8]) -> Option { None } +/// Whether a single `RTM_NEWRULE` message `msg` (the whole message, starting at +/// the `nlmsghdr`) reveals the VPN. Mirrors the app-side probe predicate exactly +/// (`check_netlink_getrule_uid`): a policy rule leaks the VPN if it names a VPN +/// interface (`FRA_IIFNAME`/`FRA_OIFNAME`), or steers this process's own uid into +/// a non-standard routing table — the per-app tun policy rule Android installs. +/// +/// `vpn_uid` is the calling app's uid (`getuid()`); the hook runs in-process so it +/// is the same uid the probe compares against. The `0..=u32::MAX` uid range is the +/// catch-all default rule, not a VPN rule, so it is excluded. +fn rule_hides_vpn(msg: &[u8], vpn_uid: u32) -> bool { + // fib_rule_hdr shares struct rtmsg's layout: the table id's low byte sits at + // header offset 4, and the full u32 (Android tun tables are > 255) arrives in + // an FRA_TABLE attribute. + let mut table = msg.get(NLMSG_HDRLEN + 4).copied().map_or(0u32, u32::from); + let mut uid_lo = 0u32; + let mut uid_hi = 0u32; + let mut has_uidrange = false; + let mut names_vpn = false; + + let mut pos = NLMSG_HDRLEN + RTMSG_HDRLEN; + while pos + 4 <= msg.len() { + let Some(rta_len) = read_u16_ne(msg, pos).map(usize::from) else { + break; + }; + let Some(rta_type) = read_u16_ne(msg, pos + 2) else { + break; + }; + if rta_len < 4 || pos + rta_len > msg.len() { + break; + } + let payload = &msg[pos + 4..pos + rta_len]; + match rta_type { + FRA_IIFNAME | FRA_OIFNAME if !payload.is_empty() => { + if is_vpn_iface_bytes(payload) { + names_vpn = true; + } + } + FRA_TABLE if payload.len() >= 4 => { + table = read_u32_ne(payload, 0).unwrap_or(table); + } + FRA_UID_RANGE if payload.len() >= 8 => { + if let (Some(lo), Some(hi)) = (read_u32_ne(payload, 0), read_u32_ne(payload, 4)) { + uid_lo = lo; + uid_hi = hi; + has_uidrange = true; + } + } + _ => {} + } + pos += rta_align(rta_len); + } + + if names_vpn { + return true; + } + has_uidrange + && uid_lo <= vpn_uid + && vpn_uid <= uid_hi + && !(uid_lo == 0 && uid_hi == u32::MAX) + && table != RT_TABLE_MAIN + && table != RT_TABLE_LOCAL + && table != RT_TABLE_DEFAULT + && table > 100 +} + fn read_u32_ne(data: &[u8], off: usize) -> Option { let bytes: &[u8; 4] = data.get(off..off + 4)?.try_into().ok()?; Some(u32::from_ne_bytes(*bytes)) @@ -307,9 +386,14 @@ fn read_u16_ne(data: &[u8], off: usize) -> Option { /// sees the VPN's default route by oif index even when the interface /// name itself is hidden, then renders it as the synthetic `if`. /// +/// `RTM_NEWRULE` (an `RTM_GETRULE` policy-rule dump) is matched by name/uid +/// rather than by index — see [`rule_hides_vpn`] — so `vpn_uid` (the calling +/// app's `getuid()`) is threaded through. This closes the routing-policy-rule +/// leak that no zygisk hook previously covered. +/// /// Returns the new valid length of the buffer. -pub fn filter_netlink_dump(data: &mut [u8], vpn_indices: &[u32]) -> usize { - if vpn_indices.is_empty() || data.len() < NLMSG_HDRLEN { +pub fn filter_netlink_dump(data: &mut [u8], vpn_indices: &[u32], vpn_uid: u32) -> usize { + if data.len() < NLMSG_HDRLEN { return data.len(); } @@ -341,6 +425,9 @@ pub fn filter_netlink_dump(data: &mut [u8], vpn_indices: &[u32]) -> usize { // Output interface is an RTA_OIF rtattr after struct rtmsg. route_oif(&data[read_pos..read_pos + nlmsg_len]) .is_some_and(|oif| vpn_indices.contains(&oif)) + } else if nlmsg_type == RTM_NEWRULE && nlmsg_len >= NLMSG_HDRLEN + RTMSG_HDRLEN { + // Policy rule: matched by VPN iface name / this uid's tun table. + rule_hides_vpn(&data[read_pos..read_pos + nlmsg_len], vpn_uid) } else { false }; @@ -518,7 +605,7 @@ tun0: 300 3 0 0\n" buf.extend(make_nlmsg(RTM_NEWADDR, wlan_idx)); let orig_msgs = 3; - let new_len = filter_netlink_dump(&mut buf, &[vpn_idx]); + let new_len = filter_netlink_dump(&mut buf, &[vpn_idx], 0); // Should have removed exactly the vpn_idx message (24 bytes). assert_eq!(new_len, 24 * (orig_msgs - 1)); @@ -537,7 +624,7 @@ tun0: 300 3 0 0\n" buf.extend(make_nlmsg(RTM_NEWLINK, vpn_idx)); buf.extend(make_nlmsg(RTM_NEWLINK, lo_idx)); - let new_len = filter_netlink_dump(&mut buf, &[vpn_idx]); + let new_len = filter_netlink_dump(&mut buf, &[vpn_idx], 0); assert_eq!(new_len, 24); // only lo remains assert_eq!(read_u16_ne(&buf, 4), Some(RTM_NEWLINK)); assert_eq!(read_u32_ne(&buf, NLMSG_HDRLEN + 4), Some(lo_idx)); @@ -550,7 +637,7 @@ tun0: 300 3 0 0\n" buf.extend(make_nlmsg(RTM_NEWADDR, 2)); let orig_len = buf.len(); - let new_len = filter_netlink_dump(&mut buf, &[99]); + let new_len = filter_netlink_dump(&mut buf, &[99], 0); assert_eq!(new_len, orig_len); } @@ -560,7 +647,7 @@ tun0: 300 3 0 0\n" buf.extend(make_nlmsg(RTM_NEWADDR, 7)); buf.extend(make_nlmsg(RTM_NEWADDR, 7)); - let new_len = filter_netlink_dump(&mut buf, &[7]); + let new_len = filter_netlink_dump(&mut buf, &[7], 0); assert_eq!(new_len, 0); } @@ -572,7 +659,7 @@ tun0: 300 3 0 0\n" buf.extend(make_nlmsg(nlmsg_done_type, 0)); // DONE — keep buf.extend(make_nlmsg(RTM_NEWADDR, 2)); // wlan — keep - let new_len = filter_netlink_dump(&mut buf, &[7]); + let new_len = filter_netlink_dump(&mut buf, &[7], 0); // Should keep DONE + wlan = 48 bytes assert_eq!(new_len, 48); assert_eq!(read_u16_ne(&buf, 4), Some(nlmsg_done_type)); @@ -584,7 +671,7 @@ tun0: 300 3 0 0\n" fn netlink_filter_empty_indices() { let mut buf = make_nlmsg(RTM_NEWADDR, 7); let orig_len = buf.len(); - let new_len = filter_netlink_dump(&mut buf, &[]); + let new_len = filter_netlink_dump(&mut buf, &[], 0); assert_eq!(new_len, orig_len); } @@ -642,7 +729,7 @@ tun0: 300 3 0 0\n" buf.extend(make_route_nlmsg(vpn_idx)); // tun0 — remove buf.extend(make_route_nlmsg(4)); // rmnet — keep - let new_len = filter_netlink_dump(&mut buf, &[vpn_idx]); + let new_len = filter_netlink_dump(&mut buf, &[vpn_idx], 0); assert_eq!(new_len, route_len * 2); // Remaining messages are wlan0 then rmnet, VPN route gone. assert_eq!(read_u32_ne(&buf, ROUTE_OIF_OFF), Some(2)); @@ -654,7 +741,7 @@ tun0: 300 3 0 0\n" // Multipath / oif-less routes must pass through untouched. let mut buf = make_route_nlmsg_inner(None); let orig_len = buf.len(); - let new_len = filter_netlink_dump(&mut buf, &[33]); + let new_len = filter_netlink_dump(&mut buf, &[33], 0); assert_eq!(new_len, orig_len); } @@ -667,9 +754,110 @@ tun0: 300 3 0 0\n" buf.extend(make_route_nlmsg(2)); // keep (wlan0) buf.extend(make_route_nlmsg(vpn_idx)); // remove (tun0) - let new_len = filter_netlink_dump(&mut buf, &[vpn_idx]); + let new_len = filter_netlink_dump(&mut buf, &[vpn_idx], 0); assert_eq!(new_len, make_route_nlmsg(0).len()); assert_eq!(read_u16_ne(&buf, 4), Some(RTM_NEWROUTE)); assert_eq!(read_u32_ne(&buf, ROUTE_OIF_OFF), Some(2)); } + + // ---- RTM_GETRULE (policy rule) tests ---- + + fn push_rule_attr(attrs: &mut Vec, rta_type: u16, payload: &[u8]) { + let rta_len = 4 + payload.len(); + attrs.extend_from_slice(&(rta_len as u16).to_ne_bytes()); + attrs.extend_from_slice(&rta_type.to_ne_bytes()); + attrs.extend_from_slice(payload); + while attrs.len() % RTA_ALIGNTO != 0 { + attrs.push(0); + } + } + + /// Build an RTM_NEWRULE message: nlmsghdr + fib_rule_hdr + optional + /// FRA_OIFNAME / FRA_UID_RANGE / FRA_TABLE attributes. Header table byte = 0. + fn make_rule_nlmsg( + oifname: Option<&[u8]>, + uidrange: Option<(u32, u32)>, + table: Option, + ) -> Vec { + let mut attrs = Vec::new(); + if let Some(name) = oifname { + let mut p = name.to_vec(); + p.push(0); + push_rule_attr(&mut attrs, FRA_OIFNAME, &p); + } + if let Some((lo, hi)) = uidrange { + let mut p = Vec::new(); + p.extend_from_slice(&lo.to_ne_bytes()); + p.extend_from_slice(&hi.to_ne_bytes()); + push_rule_attr(&mut attrs, FRA_UID_RANGE, &p); + } + if let Some(t) = table { + push_rule_attr(&mut attrs, FRA_TABLE, &t.to_ne_bytes()); + } + let body_len = NLMSG_HDRLEN + RTMSG_HDRLEN + attrs.len(); + let mut msg = Vec::new(); + msg.extend_from_slice(&(body_len as u32).to_ne_bytes()); // nlmsg_len + msg.extend_from_slice(&RTM_NEWRULE.to_ne_bytes()); // nlmsg_type + msg.extend_from_slice(&0u16.to_ne_bytes()); // nlmsg_flags + msg.extend_from_slice(&1u32.to_ne_bytes()); // nlmsg_seq + msg.extend_from_slice(&0u32.to_ne_bytes()); // nlmsg_pid + // fib_rule_hdr: family, dst_len, src_len, tos, table, res1, res2, action, flags(u32). + msg.extend_from_slice(&[2u8, 0, 0, 0, 0, 0, 0, 1]); + msg.extend_from_slice(&0u32.to_ne_bytes()); + msg.extend_from_slice(&attrs); + msg + } + + #[test] + fn rule_hides_vpn_by_oifname() { + assert!(rule_hides_vpn( + &make_rule_nlmsg(Some(b"tun0"), None, None), + 10253 + )); + assert!(!rule_hides_vpn( + &make_rule_nlmsg(Some(b"wlan0"), None, None), + 10253 + )); + } + + #[test] + fn rule_hides_vpn_by_uid_table() { + // uid 10253 steered into a non-standard table (>255) → per-app tun rule. + let msg = make_rule_nlmsg(None, Some((10253, 10253)), Some(1027)); + assert!(rule_hides_vpn(&msg, 10253)); + // another uid's rule doesn't reveal *this* app's VPN. + assert!(!rule_hides_vpn(&msg, 10000)); + } + + #[test] + fn rule_keeps_standard_tables_and_catchall() { + // main/local/default and low table ids are not VPN tables. + assert!(!rule_hides_vpn( + &make_rule_nlmsg(None, Some((10253, 10253)), Some(RT_TABLE_MAIN)), + 10253 + )); + assert!(!rule_hides_vpn( + &make_rule_nlmsg(None, Some((10253, 10253)), Some(99)), + 10253 + )); + // the 0..=u32::MAX catch-all default rule is not a VPN rule. + assert!(!rule_hides_vpn( + &make_rule_nlmsg(None, Some((0, u32::MAX)), Some(1027)), + 10253 + )); + } + + #[test] + fn netlink_filter_removes_vpn_rules() { + let uid = 10253u32; + let keep = make_rule_nlmsg(None, Some((0, u32::MAX)), Some(RT_TABLE_MAIN)); // catch-all — keep + let mut buf = Vec::new(); + buf.extend(keep.clone()); + buf.extend(make_rule_nlmsg(Some(b"tun0"), None, None)); // iface tun0 — remove + buf.extend(make_rule_nlmsg(None, Some((uid, uid)), Some(1027))); // uid→tun table — remove + + let new_len = filter_netlink_dump(&mut buf, &[7], uid); + assert_eq!(new_len, keep.len()); + assert_eq!(read_u16_ne(&buf, 4), Some(RTM_NEWRULE)); + } } diff --git a/zygisk/src/hooks.rs b/zygisk/src/hooks.rs index ccfb19f5..6a8bfec3 100644 --- a/zygisk/src/hooks.rs +++ b/zygisk/src/hooks.rs @@ -30,9 +30,9 @@ use core::{mem, ptr, slice}; use libc::{SIOCGIFCONF, SIOCGIFNAME, ifreq}; 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, + MAX_VPN_ADDRS, RTM_NEWADDR, RTM_NEWLINK, RTM_NEWROUTE, RTM_NEWRULE, 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, }; use crate::generated::hook_ids::Hook; @@ -1252,6 +1252,7 @@ pub unsafe extern "C" fn hooked_recvmsg(fd: c_int, msg: *mut libc::msghdr, flags if n == 0 { return ret; } + let vpn_uid = unsafe { libc::getuid() }; NETLINK_IOV_BUF.with(|cell| { let Ok(mut scratch) = cell.try_borrow_mut() else { @@ -1263,7 +1264,7 @@ pub unsafe extern "C" fn hooked_recvmsg(fd: c_int, msg: *mut libc::msghdr, flags hdr.msg_iovlen, in_iovecs, &mut scratch, - |data| filter_netlink_dump(data, &indices[..n]), + |data| filter_netlink_dump(data, &indices[..n], vpn_uid), ) }) else { return ret; @@ -1397,7 +1398,11 @@ unsafe fn maybe_filter_netlink_buf(fd: c_int, buf: *mut u8, ret: isize) -> isize // (RTM_GETROUTE) come back as RTM_NEWROUTE and must not be skipped — // that gap was the issue #86 `if` leak. let nlmsg_type = u16::from_ne_bytes([data[4], data[5]]); - if nlmsg_type != RTM_NEWADDR && nlmsg_type != RTM_NEWLINK && nlmsg_type != RTM_NEWROUTE { + if nlmsg_type != RTM_NEWADDR + && nlmsg_type != RTM_NEWLINK + && nlmsg_type != RTM_NEWROUTE + && nlmsg_type != RTM_NEWRULE + { return ret; } @@ -1405,8 +1410,9 @@ unsafe fn maybe_filter_netlink_buf(fd: c_int, buf: *mut u8, ret: isize) -> isize if n == 0 { return ret; } + let vpn_uid = unsafe { libc::getuid() }; - filter_netlink_dump(data, &indices[..n]) as isize + filter_netlink_dump(data, &indices[..n], vpn_uid) as isize } // ============================================================================ @@ -1806,7 +1812,7 @@ mod iovec_tests { iovecs.len(), copied, &mut scratch, - |data| filter_netlink_dump(data, &[7]), + |data| filter_netlink_dump(data, &[7], 0), ) .unwrap() }; diff --git a/zygisk/src/lib.rs b/zygisk/src/lib.rs index 306a56f0..d618f718 100644 --- a/zygisk/src/lib.rs +++ b/zygisk/src/lib.rs @@ -101,15 +101,15 @@ 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, + hooked_fstatat64, hooked_getdents64, 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_getdents64_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, @@ -683,6 +683,19 @@ fn install_hooks(hookmask: u32) -> Result { } } } + // getdents64 is a best-effort addition on top of readdir*: some bionic + // versions do not export it as a dynamic symbol, and its absence must NOT + // tear down the whole filesystem feature (readdir/open/stat still hide the + // paths). So hook it outside the fatal loop and ignore a resolution failure. + if filesystem_error.is_none() { + if let Ok(stub) = hook_libc_sym( + c"getdents64", + hooked_getdents64 as *mut _, + set_real_getdents64_ptr, + ) { + filesystem_stubs.push(stub); + } + } } let mut installed_mask = hookmask & ZYGISK_HOOK_MASK & !Hook::FilesystemIfacePaths.bit();