mirror of
https://github.com/okhsunrog/vpnhide.git
synced 2026-10-19 07:35:03 +00:00
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:
parent
e2a66ecd07
commit
887e61286a
10 changed files with 409 additions and 45 deletions
|
|
@ -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-приложений.
|
||||
|
|
@ -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 он утекал.
|
||||
|
|
@ -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` | |
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue