From 696a03370dfcd4ca7b0207c548f9efa34e2c3854 Mon Sep 17 00:00:00 2001 From: okhsunrog Date: Wed, 15 Apr 2026 23:58:28 +0300 Subject: [PATCH 1/3] refactor(lsposed): add server-side ConnectivityService hook skeleton Skeleton for migrating from Parcelable.writeToParcel hooks (which run in many contexts where Binder.getCallingUid() does not correspond to the eventual recipient) to direct hooks on ConnectivityService getters, where the calling UID is reliably the requester. No behavior change: skeleton is not wired into HookEntry yet. --- .../vpnhide/ConnectivityServiceHooks.kt | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/ConnectivityServiceHooks.kt diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/ConnectivityServiceHooks.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/ConnectivityServiceHooks.kt new file mode 100644 index 0000000..554d9dd --- /dev/null +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/ConnectivityServiceHooks.kt @@ -0,0 +1,190 @@ +package dev.okhsunrog.vpnhide + +import android.net.LinkProperties +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkInfo +import android.os.Binder +import de.robv.android.xposed.XC_MethodHook +import de.robv.android.xposed.XposedBridge +import de.robv.android.xposed.XposedHelpers + +/** + * Server-side ConnectivityService hooks. + * + * Replaces the writeToParcel-based approach in HookEntry. Rationale and + * roadmap are documented in the PR description; in short: + * + * - writeToParcel runs in many contexts (sticky broadcasts, callback + * fan-out, internal persistence) where Binder.getCallingUid() does + * NOT correspond to the recipient of the parcel. That is brittle + * and may leak modifications to non-target subscribers. + * + * - Hooking the server-side getters in ConnectivityService is the + * standard pattern: each getter runs inside the incoming Binder + * transaction from the requesting app, so getCallingUid() is the + * real caller, period. + * + * Status: SKELETON. This file installs hooks but the per-method handlers + * are not yet wired to actual filtering — see TODO blocks below. The old + * writeToParcel hooks in HookEntry remain active for the time being so + * functionality is not regressed during the migration. + */ +internal object ConnectivityServiceHooks { + private const val SERVICE_CLASS = "com.android.server.ConnectivityService" + + /** + * Methods on ConnectivityService that return network state and that + * we want to filter for target callers. The list is ordered roughly + * from "most commonly called by apps" to "edge cases". + * + * Names only — XposedHelpers.findAndHookMethod resolves overloads at + * install time. Some of these have multiple signatures across AOSP + * versions (Android 11/12/13/14/15); we hook by name and let the + * handler inspect args at runtime where needed. + */ + private val TARGET_METHODS = + listOf( + // NetworkInfo getters — legacy API but still widely used by + // older apps and SDKs (AppsFlyer, Adjust, banking SDKs). + "getActiveNetworkInfo", + "getActiveNetworkInfoForUid", + "getNetworkInfo", + "getNetworkInfoForUid", + "getAllNetworkInfo", + // Network handle getters — used to look up a Network object, + // which is then passed to getNetworkCapabilities/getLinkProperties. + "getActiveNetwork", + "getActiveNetworkForUid", + "getAllNetworks", + // NetworkCapabilities + LinkProperties — modern API surface, + // primary path on Android 11+. + "getNetworkCapabilities", + "getLinkProperties", + "getActiveLinkProperties", + // Default network capabilities by user — used by some + // ConnectivityManager.getDefaultNetworkCapabilitiesForUser + // wrappers; rarely invoked but cheap to cover. + "getDefaultNetworkCapabilitiesForUser", + ) + + fun install( + classLoader: ClassLoader, + isTargetCaller: () -> Boolean, + sanitizeNetworkCapabilities: (NetworkCapabilities) -> NetworkCapabilities?, + sanitizeNetworkInfo: (NetworkInfo) -> NetworkInfo?, + sanitizeLinkProperties: (LinkProperties) -> LinkProperties?, + ) { + val cls = + try { + XposedHelpers.findClass(SERVICE_CLASS, classLoader) + } catch (t: Throwable) { + XposedBridge.log("VpnHide: ConnectivityService class not found: ${t.message}") + return + } + + var hookedCount = 0 + for (methodName in TARGET_METHODS) { + // Hook ALL overloads of the method — across AOSP versions + // signatures vary, and some methods have @hide variants. + val methods = + cls.declaredMethods.filter { it.name == methodName } + if (methods.isEmpty()) { + // Method not present on this AOSP version — fine, skip. + continue + } + for (m in methods) { + try { + XposedBridge.hookMethod( + m, + object : XC_MethodHook() { + override fun afterHookedMethod(param: MethodHookParam) { + // Bail fast for non-target callers — keep + // hot path under a single Set.contains(). + if (!isTargetCaller()) return + + val original = param.result ?: return + val sanitized: Any? = + when (original) { + is NetworkCapabilities -> { + sanitizeNetworkCapabilities(original) + } + + is NetworkInfo -> { + sanitizeNetworkInfo(original) + } + + is LinkProperties -> { + sanitizeLinkProperties(original) + } + + is Array<*> -> { + sanitizeArray( + original, + sanitizeNetworkCapabilities, + sanitizeNetworkInfo, + sanitizeLinkProperties, + ) + } + + is Network -> { + // TODO(refactor): hide VPN Network handle entirely + // by returning null when the underlying NC has + // TRANSPORT_VPN. Needs cross-call lookup of NC by + // Network handle — wire up later. + null + } + + else -> { + null + } + } + if (sanitized != null) { + param.result = sanitized + } + } + }, + ) + hookedCount++ + } catch (t: Throwable) { + XposedBridge.log( + "VpnHide: failed to hook ConnectivityService.$methodName " + + "(${m.parameterTypes.joinToString { it.simpleName }}): ${t.message}", + ) + } + } + } + XposedBridge.log("VpnHide: installed $hookedCount ConnectivityService hooks") + } + + /** + * Sanitize an array result (e.g. getAllNetworkInfo, getAllNetworks). + * Returns a new array with VPN entries removed/sanitized, or null if + * no changes were needed. + */ + private fun sanitizeArray( + original: Array<*>, + sanitizeNc: (NetworkCapabilities) -> NetworkCapabilities?, + sanitizeNi: (NetworkInfo) -> NetworkInfo?, + sanitizeLp: (LinkProperties) -> LinkProperties?, + ): Array<*>? { + // TODO(refactor): implement per-element-type filtering. + // For NetworkInfo[]: drop VPN entries entirely. + // For Network[]: filter out handles that resolve to VPN networks. + // For NetworkCapabilities[]: sanitize each element. + return null + } + + /** + * Marker for the "shall I touch this caller" check. Provided by + * HookEntry so the cache invalidation path stays in one place. + */ + @Suppress("unused") + private fun callerIsTarget(): Boolean = + false.also { + // Placeholder; real impl is injected via the lambda passed to + // install(). Kept here so future readers can grep the file for + // the concept. + Binder.getCallingUid() + } +} From a761db51529b74a5acdb6c00b2365ef681cfcede Mon Sep 17 00:00:00 2001 From: okhsunrog Date: Thu, 16 Apr 2026 00:52:37 +0300 Subject: [PATCH 2/3] refactor(lsposed): extract sanitizers + wire up ConnectivityService hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the VPN-stripping logic out of HookEntry's writeToParcel handlers into a new NetworkSanitizers object with three pure functions (sanitizeNetworkCapabilities, sanitizeNetworkInfo, sanitizeLinkProperties). Each returns a modified copy or null when no change is needed — no in-place mutation of objects the ConnectivityService may be sharing across threads. Wire the previously dormant ConnectivityServiceHooks.install() from HookEntry now that the sanitizers are callable from both code paths. The writeToParcel hooks stay in place as a defensive fallback for callback/broadcast paths the server-side hooks may not yet cover. Array results (NetworkInfo[], Network[], NetworkCapabilities[]) and Network handle hiding are still TODO in ConnectivityServiceHooks. Basic coverage today: NetworkInfo getters, NetworkCapabilities, LinkProperties scalar getters. --- .../kotlin/dev/okhsunrog/vpnhide/HookEntry.kt | 173 +++-------------- .../okhsunrog/vpnhide/NetworkSanitizers.kt | 177 ++++++++++++++++++ 2 files changed, 205 insertions(+), 145 deletions(-) create mode 100644 lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/NetworkSanitizers.kt diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/HookEntry.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/HookEntry.kt index 563cf5e..446f582 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/HookEntry.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/HookEntry.kt @@ -3,7 +3,6 @@ package dev.okhsunrog.vpnhide import android.net.LinkProperties import android.net.NetworkCapabilities import android.net.NetworkInfo -import android.net.RouteInfo import android.os.Binder import de.robv.android.xposed.IXposedHookLoadPackage import de.robv.android.xposed.XC_MethodHook @@ -50,7 +49,7 @@ class HookEntry : IXposedHookLoadPackage { if (hookInstalled.compareAndSet(false, true)) { XposedBridge.log("VpnHide: system_server detected, installing Binder hooks") - installSystemServerHooks() + installSystemServerHooks(lpparam.classLoader) tryHook("PackageVisibility") { PackageVisibilityHooks.install(lpparam.classLoader) } writeHookStatusFile() } @@ -68,91 +67,11 @@ class HookEntry : IXposedHookLoadPackage { } // ------------------------------------------------------------------ - // Helpers + // Helpers — sanitise logic lives in NetworkSanitizers so both the + // legacy writeToParcel hooks below and the new ConnectivityService + // getter hooks share one implementation. // ------------------------------------------------------------------ - private fun isVpnInterfaceName(name: String): Boolean { - if (name.isEmpty()) return false - val n = name.lowercase() - return n.startsWith("tun") || - n.startsWith("ppp") || - n.startsWith("tap") || - n.startsWith("wg") || - n.startsWith("ipsec") || - n.startsWith("xfrm") || - n.startsWith("utun") || - n.startsWith("l2tp") || - n.startsWith("gre") || - n.contains("vpn") - } - - private fun sanitizeLinkProperties(copy: LinkProperties): Boolean { - var modified = false - - val ifaceName = XposedHelpers.getObjectField(copy, "mIfaceName") as? String - if (ifaceName != null && isVpnInterfaceName(ifaceName)) { - XposedHelpers.setObjectField(copy, "mIfaceName", null) - modified = true - } - - try { - @Suppress("UNCHECKED_CAST") - val routesField = XposedHelpers.getObjectField(copy, "mRoutes") as? MutableList - if (routesField != null) { - val filtered = - routesField.filterNot { route -> - val routeIface = route.`interface` - routeIface != null && isVpnInterfaceName(routeIface) - } - if (filtered.size != routesField.size) { - routesField.clear() - routesField.addAll(filtered) - modified = true - } - } - } catch (t: Throwable) { - XposedBridge.log("VpnHide: failed to sanitize mRoutes: ${t.message}") - } - - try { - @Suppress("UNCHECKED_CAST") - val stacked = XposedHelpers.getObjectField(copy, "mStackedLinks") as? MutableMap - if (stacked != null && stacked.isNotEmpty()) { - val filtered = LinkedHashMap() - for ((key, value) in stacked) { - val stackedCopy = - try { - val ctor = LinkProperties::class.java.getDeclaredConstructor(LinkProperties::class.java) - ctor.isAccessible = true - ctor.newInstance(value) as LinkProperties - } catch (_: Throwable) { - value - } - val stackedModified = sanitizeLinkProperties(stackedCopy) - val stackedIface = XposedHelpers.getObjectField(stackedCopy, "mIfaceName") as? String - if (stackedIface == null && stackedCopy.routes.isEmpty()) { - if (stackedModified || isVpnInterfaceName(key)) { - modified = true - } else { - filtered[key] = stackedCopy - } - } else { - if (stackedModified || stackedCopy !== value) modified = true - filtered[key] = stackedCopy - } - } - if (filtered.size != stacked.size || modified) { - stacked.clear() - stacked.putAll(filtered) - } - } - } catch (t: Throwable) { - XposedBridge.log("VpnHide: failed to sanitize mStackedLinks: ${t.message}") - } - - return modified - } - // ================================================================== // system_server hooks — per-UID Binder filtering // ================================================================== @@ -205,10 +124,19 @@ class HookEntry : IXposedHookLoadPackage { systemServerTargetUids = null } - private fun installSystemServerHooks() { + private fun installSystemServerHooks(classLoader: ClassLoader) { tryHook("NC.writeToParcel") { hookNCWriteToParcel() } tryHook("NI.writeToParcel") { hookNIWriteToParcel() } tryHook("LP.writeToParcel") { hookLPWriteToParcel() } + tryHook("ConnectivityService getters") { + ConnectivityServiceHooks.install( + classLoader = classLoader, + isTargetCaller = ::isTargetCaller, + sanitizeNetworkCapabilities = NetworkSanitizers::sanitizeNetworkCapabilities, + sanitizeNetworkInfo = NetworkSanitizers::sanitizeNetworkInfo, + sanitizeLinkProperties = NetworkSanitizers::sanitizeLinkProperties, + ) + } tryHook("FileObserver") { watchTargetUidsFile() } } @@ -260,11 +188,13 @@ class HookEntry : IXposedHookLoadPackage { } /** - * Hook NetworkCapabilities.writeToParcel in system_server. - * For target UIDs, creates a copy with VPN stripped and writes - * the copy to the Parcel instead of the original. The original - * object is never mutated, avoiding race conditions with - * ConnectivityService threads. + * Hook NetworkCapabilities.writeToParcel — defensive fallback for + * callback/broadcast paths the ConnectivityService getter hooks may + * not cover. For target UIDs, writes a VPN-stripped copy to the + * Parcel instead of the original; the original object is never + * mutated. + * + * Slated for removal once server-side hooks prove sufficient. */ private fun hookNCWriteToParcel() { val writingCopy = ThreadLocal() @@ -276,31 +206,16 @@ class HookEntry : IXposedHookLoadPackage { object : XC_MethodHook() { override fun beforeHookedMethod(param: MethodHookParam) { if (writingCopy.get() == true) return - if (!loadTargetUids().contains(Binder.getCallingUid())) return + if (!isTargetCaller()) return val nc = param.thisObject as NetworkCapabilities try { - val transportTypes = XposedHelpers.getLongField(nc, "mTransportTypes") - val vpnBit = 1L shl TRANSPORT_VPN - if (transportTypes and vpnBit == 0L) return - - val copy = NetworkCapabilities(nc) - XposedHelpers.setLongField(copy, "mTransportTypes", transportTypes and vpnBit.inv()) - val caps = XposedHelpers.getLongField(copy, "mNetworkCapabilities") - XposedHelpers.setLongField(copy, "mNetworkCapabilities", caps or (1L shl NET_CAPABILITY_NOT_VPN)) - try { - val ti = XposedHelpers.getObjectField(copy, "mTransportInfo") - if (ti != null && ti.javaClass.name == "android.net.VpnTransportInfo") { - XposedHelpers.setObjectField(copy, "mTransportInfo", null) - } - } catch (_: Throwable) { - } - + val sanitized = NetworkSanitizers.sanitizeNetworkCapabilities(nc) ?: return val parcel = param.args[0] as android.os.Parcel val flags = param.args[1] as Int writingCopy.set(true) try { - copy.writeToParcel(parcel, flags) + sanitized.writeToParcel(parcel, flags) } finally { writingCopy.set(false) } @@ -314,10 +229,6 @@ class HookEntry : IXposedHookLoadPackage { XposedBridge.log("VpnHide: hooked NetworkCapabilities.writeToParcel") } - /** - * Hook NetworkInfo.writeToParcel — disguise VPN NetworkInfo for target callers. - * Creates a copy with type changed from VPN to WIFI, writes the copy. - */ @Suppress("DEPRECATION") private fun hookNIWriteToParcel() { val writingCopy = ThreadLocal() @@ -332,27 +243,12 @@ class HookEntry : IXposedHookLoadPackage { if (!isTargetCaller()) return val ni = param.thisObject as NetworkInfo try { - val type = XposedHelpers.getIntField(ni, "mNetworkType") - if (type != TYPE_VPN) return - - val ctor = - NetworkInfo::class.java.getDeclaredConstructor( - Integer.TYPE, - Integer.TYPE, - String::class.java, - String::class.java, - ) - ctor.isAccessible = true - val copy = ctor.newInstance(TYPE_WIFI, 0, "WIFI", "") as NetworkInfo - XposedHelpers.setIntField(copy, "mState", XposedHelpers.getIntField(ni, "mState")) - XposedHelpers.setIntField(copy, "mDetailedState", XposedHelpers.getIntField(ni, "mDetailedState")) - XposedHelpers.setBooleanField(copy, "mIsAvailable", XposedHelpers.getBooleanField(ni, "mIsAvailable")) - + val sanitized = NetworkSanitizers.sanitizeNetworkInfo(ni) ?: return val parcel = param.args[0] as android.os.Parcel val flags = param.args[1] as Int writingCopy.set(true) try { - copy.writeToParcel(parcel, flags) + sanitized.writeToParcel(parcel, flags) } finally { writingCopy.set(false) } @@ -366,11 +262,6 @@ class HookEntry : IXposedHookLoadPackage { XposedBridge.log("VpnHide: hooked NetworkInfo.writeToParcel") } - /** - * Hook LinkProperties.writeToParcel — clear VPN interface name and - * routes for target callers. Creates a copy to avoid mutating the - * original object shared by ConnectivityService threads. - */ private fun hookLPWriteToParcel() { val writingCopy = ThreadLocal() XposedHelpers.findAndHookMethod( @@ -384,16 +275,12 @@ class HookEntry : IXposedHookLoadPackage { if (!isTargetCaller()) return val lp = param.thisObject as LinkProperties try { - val ctor = LinkProperties::class.java.getDeclaredConstructor(LinkProperties::class.java) - ctor.isAccessible = true - val copy = ctor.newInstance(lp) as LinkProperties - if (!sanitizeLinkProperties(copy)) return - + val sanitized = NetworkSanitizers.sanitizeLinkProperties(lp) ?: return val parcel = param.args[0] as android.os.Parcel val flags = param.args[1] as Int writingCopy.set(true) try { - copy.writeToParcel(parcel, flags) + sanitized.writeToParcel(parcel, flags) } finally { writingCopy.set(false) } @@ -408,10 +295,6 @@ class HookEntry : IXposedHookLoadPackage { } companion object { - private const val TRANSPORT_VPN = 4 - private const val NET_CAPABILITY_NOT_VPN = 15 - private const val TYPE_VPN = 17 - private const val TYPE_WIFI = 1 const val HOOK_STATUS_FILE = "/data/system/vpnhide_hook_active" } } diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/NetworkSanitizers.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/NetworkSanitizers.kt new file mode 100644 index 0000000..dec8999 --- /dev/null +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/NetworkSanitizers.kt @@ -0,0 +1,177 @@ +package dev.okhsunrog.vpnhide + +import android.net.LinkProperties +import android.net.NetworkCapabilities +import android.net.NetworkInfo +import android.net.RouteInfo +import de.robv.android.xposed.XposedBridge +import de.robv.android.xposed.XposedHelpers + +/** + * Pure functions that produce a "VPN-free" view of the three network + * state objects we filter. Each returns a new object with VPN details + * stripped, or `null` if the input did not contain any VPN signal (so + * callers can skip the work and pass the original through). + * + * Used by both the legacy `writeToParcel` hooks in `HookEntry` and the + * new server-side `ConnectivityService` getter hooks. Neither caller + * ever mutates the input object — all changes land on the returned + * copy, which keeps broadcast/callback paths that share the original + * safe. + */ +internal object NetworkSanitizers { + const val TRANSPORT_VPN = 4 + const val NET_CAPABILITY_NOT_VPN = 15 + const val TYPE_VPN = 17 + const val TYPE_WIFI = 1 + + fun isVpnInterfaceName(name: String): Boolean { + if (name.isEmpty()) return false + val n = name.lowercase() + return n.startsWith("tun") || + n.startsWith("ppp") || + n.startsWith("tap") || + n.startsWith("wg") || + n.startsWith("ipsec") || + n.startsWith("xfrm") || + n.startsWith("utun") || + n.startsWith("l2tp") || + n.startsWith("gre") || + n.contains("vpn") + } + + /** + * Returns a NetworkCapabilities copy with TRANSPORT_VPN cleared, + * NOT_VPN capability set, and VpnTransportInfo removed. Returns + * null if the input has no VPN transport (no work to do). + */ + fun sanitizeNetworkCapabilities(nc: NetworkCapabilities): NetworkCapabilities? { + val transportTypes = XposedHelpers.getLongField(nc, "mTransportTypes") + val vpnBit = 1L shl TRANSPORT_VPN + if (transportTypes and vpnBit == 0L) return null + + val copy = NetworkCapabilities(nc) + XposedHelpers.setLongField(copy, "mTransportTypes", transportTypes and vpnBit.inv()) + val caps = XposedHelpers.getLongField(copy, "mNetworkCapabilities") + XposedHelpers.setLongField(copy, "mNetworkCapabilities", caps or (1L shl NET_CAPABILITY_NOT_VPN)) + try { + val ti = XposedHelpers.getObjectField(copy, "mTransportInfo") + if (ti != null && ti.javaClass.name == "android.net.VpnTransportInfo") { + XposedHelpers.setObjectField(copy, "mTransportInfo", null) + } + } catch (_: Throwable) { + // mTransportInfo doesn't exist on older Android — ignore + } + return copy + } + + /** + * Returns a WIFI-typed NetworkInfo copy preserving the original + * state. Returns null if the input isn't a VPN NetworkInfo. + */ + @Suppress("DEPRECATION") + fun sanitizeNetworkInfo(ni: NetworkInfo): NetworkInfo? { + val type = XposedHelpers.getIntField(ni, "mNetworkType") + if (type != TYPE_VPN) return null + + val ctor = + NetworkInfo::class.java.getDeclaredConstructor( + Integer.TYPE, + Integer.TYPE, + String::class.java, + String::class.java, + ) + ctor.isAccessible = true + val copy = ctor.newInstance(TYPE_WIFI, 0, "WIFI", "") as NetworkInfo + XposedHelpers.setIntField(copy, "mState", XposedHelpers.getIntField(ni, "mState")) + XposedHelpers.setIntField(copy, "mDetailedState", XposedHelpers.getIntField(ni, "mDetailedState")) + XposedHelpers.setBooleanField(copy, "mIsAvailable", XposedHelpers.getBooleanField(ni, "mIsAvailable")) + return copy + } + + /** + * Returns a LinkProperties copy with VPN interface name and + * VPN-bound routes stripped (recursively across `mStackedLinks`). + * Returns null if no VPN signal was present (no copy needed). + */ + fun sanitizeLinkProperties(lp: LinkProperties): LinkProperties? { + val ctor = LinkProperties::class.java.getDeclaredConstructor(LinkProperties::class.java) + ctor.isAccessible = true + val copy = ctor.newInstance(lp) as LinkProperties + return if (sanitizeInPlace(copy)) copy else null + } + + /** + * Mutates `copy` in place to strip VPN data. Returns true if any + * change was made. Recurses through `mStackedLinks`. + * + * Separate from `sanitizeLinkProperties` so the recursive path + * can reuse the mutation without allocating an outer copy. + */ + private fun sanitizeInPlace(copy: LinkProperties): Boolean { + var modified = false + + val ifaceName = XposedHelpers.getObjectField(copy, "mIfaceName") as? String + if (ifaceName != null && isVpnInterfaceName(ifaceName)) { + XposedHelpers.setObjectField(copy, "mIfaceName", null) + modified = true + } + + try { + @Suppress("UNCHECKED_CAST") + val routesField = XposedHelpers.getObjectField(copy, "mRoutes") as? MutableList + if (routesField != null) { + val filtered = + routesField.filterNot { route -> + val routeIface = route.`interface` + routeIface != null && isVpnInterfaceName(routeIface) + } + if (filtered.size != routesField.size) { + routesField.clear() + routesField.addAll(filtered) + modified = true + } + } + } catch (t: Throwable) { + XposedBridge.log("VpnHide: failed to sanitize mRoutes: ${t.message}") + } + + try { + @Suppress("UNCHECKED_CAST") + val stacked = XposedHelpers.getObjectField(copy, "mStackedLinks") as? MutableMap + if (stacked != null && stacked.isNotEmpty()) { + val filtered = LinkedHashMap() + for ((key, value) in stacked) { + val stackedCopy = + try { + val ctor = LinkProperties::class.java.getDeclaredConstructor(LinkProperties::class.java) + ctor.isAccessible = true + ctor.newInstance(value) as LinkProperties + } catch (_: Throwable) { + value + } + val stackedModified = sanitizeInPlace(stackedCopy) + val stackedIface = XposedHelpers.getObjectField(stackedCopy, "mIfaceName") as? String + if (stackedIface == null && stackedCopy.routes.isEmpty()) { + if (stackedModified || isVpnInterfaceName(key)) { + modified = true + } else { + filtered[key] = stackedCopy + } + } else { + if (stackedModified || stackedCopy !== value) modified = true + filtered[key] = stackedCopy + } + } + if (filtered.size != stacked.size || modified) { + stacked.clear() + stacked.putAll(filtered) + } + } + } catch (t: Throwable) { + XposedBridge.log("VpnHide: failed to sanitize mStackedLinks: ${t.message}") + } + + return modified + } +} From ee02c58166c5b4768ed0afae23020d9d341cd751 Mon Sep 17 00:00:00 2001 From: okhsunrog Date: Thu, 16 Apr 2026 01:00:31 +0300 Subject: [PATCH 3/3] refactor(lsposed): implement array sanitization, clarify Network-handle policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fill in sanitizeArray so NetworkInfo[] (from getAllNetworkInfo) and NetworkCapabilities[] (from getDefaultNetworkCapabilitiesForUser) get per-element sanitization. Length is preserved and VPN elements are replaced with their disguised copies, matching the writeToParcel semantics callers rely on. Network[] and single Network handles are left untouched on purpose: handles are opaque references, and the follow-up getNetworkCapabilities(handle) / getLinkProperties(handle) calls go through our other hooks which do the real filtering. Swapping handles is both harder (need an NC lookup) and not actually required. Also refresh the file-level doc — it's no longer a skeleton. --- .../vpnhide/ConnectivityServiceHooks.kt | 105 +++++++++++------- 1 file changed, 62 insertions(+), 43 deletions(-) diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/ConnectivityServiceHooks.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/ConnectivityServiceHooks.kt index 554d9dd..b3abbff 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/ConnectivityServiceHooks.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/ConnectivityServiceHooks.kt @@ -1,10 +1,8 @@ package dev.okhsunrog.vpnhide import android.net.LinkProperties -import android.net.Network import android.net.NetworkCapabilities import android.net.NetworkInfo -import android.os.Binder import de.robv.android.xposed.XC_MethodHook import de.robv.android.xposed.XposedBridge import de.robv.android.xposed.XposedHelpers @@ -12,23 +10,23 @@ import de.robv.android.xposed.XposedHelpers /** * Server-side ConnectivityService hooks. * - * Replaces the writeToParcel-based approach in HookEntry. Rationale and - * roadmap are documented in the PR description; in short: + * Replaces the writeToParcel-based approach in HookEntry. Rationale: * * - writeToParcel runs in many contexts (sticky broadcasts, callback - * fan-out, internal persistence) where Binder.getCallingUid() does - * NOT correspond to the recipient of the parcel. That is brittle - * and may leak modifications to non-target subscribers. + * fan-out, internal persistence) where `Binder.getCallingUid()` + * does NOT correspond to the recipient of the parcel. That's + * brittle and may leak modifications to non-target subscribers. * * - Hooking the server-side getters in ConnectivityService is the * standard pattern: each getter runs inside the incoming Binder - * transaction from the requesting app, so getCallingUid() is the - * real caller, period. + * transaction from the requesting app, so `getCallingUid()` is + * the real caller, period. * - * Status: SKELETON. This file installs hooks but the per-method handlers - * are not yet wired to actual filtering — see TODO blocks below. The old - * writeToParcel hooks in HookEntry remain active for the time being so - * functionality is not regressed during the migration. + * The `NetworkSanitizers` object provides the pure sanitization logic; + * this file only handles hook installation and result dispatch. + * + * HookEntry keeps the writeToParcel fallback active in parallel for + * callback/broadcast paths these server-side hooks don't cover. */ internal object ConnectivityServiceHooks { private const val SERVICE_CLASS = "com.android.server.ConnectivityService" @@ -123,18 +121,14 @@ internal object ConnectivityServiceHooks { original, sanitizeNetworkCapabilities, sanitizeNetworkInfo, - sanitizeLinkProperties, ) } - is Network -> { - // TODO(refactor): hide VPN Network handle entirely - // by returning null when the underlying NC has - // TRANSPORT_VPN. Needs cross-call lookup of NC by - // Network handle — wire up later. - null - } - + // Network handles are opaque references; + // the sanitization happens when the caller + // queries getNetworkCapabilities(handle) / + // getLinkProperties(handle), both of which + // we also hook. No need to swap the handle. else -> { null } @@ -158,33 +152,58 @@ internal object ConnectivityServiceHooks { } /** - * Sanitize an array result (e.g. getAllNetworkInfo, getAllNetworks). - * Returns a new array with VPN entries removed/sanitized, or null if - * no changes were needed. + * Sanitize an array result. Covers: + * + * - `NetworkInfo[]` from `getAllNetworkInfo` + * - `NetworkCapabilities[]` from `getDefaultNetworkCapabilitiesForUser` + * + * `Network[]` is deliberately left untouched — handles are opaque, + * filtering happens at the follow-up `getNetworkCapabilities(handle)` + * / `getLinkProperties(handle)` call. + * + * Array length is preserved and VPN elements are replaced with their + * sanitized copies (WIFI-disguised for NetworkInfo, VPN bits cleared + * for NetworkCapabilities). Preserving the length matches the legacy + * writeToParcel semantics — callers that index into the array or use + * `.size` for UI/state decisions see no surprise. + * + * Returns a new array with the substitutions applied, or null when + * nothing was changed (caller passes through the original). */ private fun sanitizeArray( original: Array<*>, sanitizeNc: (NetworkCapabilities) -> NetworkCapabilities?, sanitizeNi: (NetworkInfo) -> NetworkInfo?, - sanitizeLp: (LinkProperties) -> LinkProperties?, ): Array<*>? { - // TODO(refactor): implement per-element-type filtering. - // For NetworkInfo[]: drop VPN entries entirely. - // For Network[]: filter out handles that resolve to VPN networks. - // For NetworkCapabilities[]: sanitize each element. - return null - } + if (original.isEmpty()) return null + val componentType = original.javaClass.componentType ?: return null - /** - * Marker for the "shall I touch this caller" check. Provided by - * HookEntry so the cache invalidation path stays in one place. - */ - @Suppress("unused") - private fun callerIsTarget(): Boolean = - false.also { - // Placeholder; real impl is injected via the lambda passed to - // install(). Kept here so future readers can grep the file for - // the concept. - Binder.getCallingUid() + var modified = false + val replacements = arrayOfNulls(original.size) + for ((i, element) in original.withIndex()) { + val sanitized: Any? = + when (element) { + is NetworkInfo -> sanitizeNi(element) + is NetworkCapabilities -> sanitizeNc(element) + else -> null + } + if (sanitized != null) { + replacements[i] = sanitized + modified = true + } else { + replacements[i] = element + } } + if (!modified) return null + + // Build a result array of the correct component type so Android + // APIs that cast the Binder reply back to `NetworkInfo[]` etc. + // don't hit ClassCastException. + @Suppress("UNCHECKED_CAST") + val result = + java.lang.reflect.Array + .newInstance(componentType, original.size) as Array + for (i in original.indices) result[i] = replacements[i] + return result + } }