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
This commit is contained in:
okhsunrog 2026-08-15 19:40:53 +03:00
parent e2a66ecd07
commit 887e61286a
10 changed files with 409 additions and 45 deletions

View file

@ -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-приложений.

View file

@ -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 он утекал.

View file

@ -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/<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 |
| `/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 (incl. `readdir`/`getdents64` listing filter) | — | 🔒 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
@ -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` | |

View file

@ -78,11 +78,20 @@ internal val NATIVE_CHECKS: List<NativeCheckSpec> =
),
),
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",

View file

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

View file

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

View file

@ -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::<u8>(), 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<u8> {
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);
}
}

View file

@ -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<u32> {
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<u32> {
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<u16> {
/// sees the VPN's default route by oif index even when the interface
/// name itself is hidden, then renders it as the synthetic `if<index>`.
///
/// `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<u8>, 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<u32>,
) -> Vec<u8> {
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));
}
}

View file

@ -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<N>` 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()
};

View file

@ -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<HookInstallReport, String> {
}
}
}
// 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();