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..b3abbff --- /dev/null +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/ConnectivityServiceHooks.kt @@ -0,0 +1,209 @@ +package dev.okhsunrog.vpnhide + +import android.net.LinkProperties +import android.net.NetworkCapabilities +import android.net.NetworkInfo +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: + * + * - 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'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. + * + * 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" + + /** + * 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, + ) + } + + // 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 + } + } + 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. 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?, + ): Array<*>? { + if (original.isEmpty()) return null + val componentType = original.javaClass.componentType ?: return null + + 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 + } +} 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 + } +}