Skip to content

refactor(lsposed): hook ConnectivityService getters instead of writeToParcel#24

Draft
okhsunrog wants to merge 3 commits intomainfrom
refactor/lsposed-server-side-hooks
Draft

refactor(lsposed): hook ConnectivityService getters instead of writeToParcel#24
okhsunrog wants to merge 3 commits intomainfrom
refactor/lsposed-server-side-hooks

Conversation

@okhsunrog
Copy link
Copy Markdown
Owner

Background

The current LSPosed hooks live on Parcelable.writeToParcel() of NetworkCapabilities, NetworkInfo, and LinkProperties (HookEntry.kt:209-211). Inside each hook we look up the caller via Binder.getCallingUid() and, if the caller is in the target list, write a sanitised copy into the parcel instead.

This works for the common case (a target app calls ConnectivityManager.getActiveNetworkInfo() over Binder; the response is serialised in system_server in the same Binder context; getCallingUid() returns the right UID). It is, however, architecturally fragile.

Problem

writeToParcel() is called in many contexts where the current Binder calling UID is not the eventual recipient of the parcel:

  • Sticky broadcastsConnectivityService posts CONNECTIVITY_ACTION (and friends) with EXTRA_NETWORK_INFO. Serialisation happens on ConnectivityService's handler thread; whether getCallingUid() returns the original requester, SYSTEM_UID, or stale state from the last incoming transaction depends on Android version and timing.
  • NetworkCallback fan-out — one NetworkCapabilities object is sent to N subscribers. Modifying it once based on "some" caller's UID is semantically wrong.
  • Internal persistence / dump / cache fill — same risk.
  • IpcDataCache (Android 11+)ConnectivityManager caches results client-side. Server-side writeToParcel modification interacts oddly with the cache invalidation protocol.

In addition, the writeToParcel hook fires for every serialisation of these objects across the system, even when the caller is not in our target list; for non-targets we exit early after a Set.contains(), but the dispatch itself plus reflection access in the entry block (getLongField etc. for the bail-fast check on NC) is still per-call overhead.

Symptoms reported

#16 — multiple users on Magisk Kitsune report apps hanging on infinite loading (Ozon, Home Assistant, unofficial 4pda client, slow Chrome cold start) even when the affected app is excluded from the VPN Hide target list, or never added. I am not yet 100% sure this refactor fixes that specific report — it might be unrelated Magisk Kitsune zygisk overhead — but the underlying architecture is brittle regardless and worth fixing on its own.

Proposal

Move filtering to server-side getters in com.android.server.ConnectivityService — specifically:

  • getActiveNetworkInfo, getActiveNetworkInfoForUid, getNetworkInfo, getNetworkInfoForUid, getAllNetworkInfo
  • getActiveNetwork, getActiveNetworkForUid, getAllNetworks
  • getNetworkCapabilities, getLinkProperties, getActiveLinkProperties
  • getDefaultNetworkCapabilitiesForUser

Each of these runs strictly inside the incoming Binder transaction from the requesting app, so Binder.getCallingUid() is the real caller without ambiguity. We replace the result via param.result = ... in afterHookedMethod. AOSP version differences in method signatures are handled by hooking by name and walking declaredMethods.

Open questions / TODO

  • NetworkCallback path — the writeToParcel approach was the only thing covering callbacks. For target apps that subscribe to NetworkCallback, callback delivery sends NetworkCapabilities / LinkProperties to the app's process. Need to figure out the right hook for that. Options:
    • Hook IConnectivityManager#registerNetworkCallback and wrap the messenger
    • Hook in zygisk on the client side
    • Keep writeToParcel as a fallback only for the messenger fan-out path
  • Network handle hidinggetActiveNetwork() / getAllNetworks() return opaque Network handles. Hiding a VPN here means returning null / filtering the array, which requires a cross-call lookup of which Network is the VPN. Skeleton has a TODO.
  • IpcDataCache invalidation — when our list of targets changes, we likely need to nudge ConnectivityManager to drop its cache for the affected app(s).
  • Wire up ConnectivityServiceHooks.install(...) from HookEntry with the actual sanitising lambdas (currently those lambdas live as private helpers in HookEntry and need to be extracted / made callable).
  • Decide rollout — feature flag, or just replace?
  • Test on Android 11 / 12 / 13 / 14 / 15 — method signatures vary; need to confirm declaredMethods walking finds everything we expect on each.

What this PR actually contains right now

A skeleton file ConnectivityServiceHooks.kt with the hook installation scaffolding, the list of methods to cover, and the dispatch table for sanitising results by type. It is not wired into HookEntry yet, so there is no behaviour change. The skeleton is intentionally compile-only so this PR can be merged (or kept as draft) without risk while we work through the open questions above.

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.
…ooks

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.
@okhsunrog okhsunrog force-pushed the refactor/lsposed-server-side-hooks branch from 9d56f17 to a761db5 Compare April 15, 2026 21:52
…le policy

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.
okhsunrog added a commit that referenced this pull request Apr 26, 2026
Mastade pushed a commit to Mastade/vpnhide that referenced this pull request Apr 27, 2026
…khsunrog#24 okhsunrog#31 okhsunrog#32 okhsunrog#37)

Six small review-list items rolled together — all CI/dev-tooling, no
runtime behaviour change.

  okhsunrog#12  Dockerfile: pin Rust 1.95.0 and cargo-ndk 4.1.2 (was floating
       `stable` + latest cargo-ndk on monthly rebuild). Versions live
       in ENV vars to make the next bump a one-line edit.

  okhsunrog#13  Add shellcheck to lint job. SC2034/SC3043 excluded — Magisk
       reads SKIPUNZIP externally; Android's /system/bin/sh (mksh on
       Pixel) does support `local` despite POSIX. Verified locally
       that the 11 .sh files (module-side + dev tooling) pass.
       shellcheck baked into the CI image via apt; inline apt-get
       fallback covers the window before image rebuild.

  okhsunrog#24  ci.yml keystore.properties: replace heredoc with `printf '%s\n'`.
       Heredoc without single-quoted EOF re-expands $, backticks and
       backslashes in the password — printf takes the value verbatim.

  okhsunrog#31  scripts/release.py::patch_file now hard-fails when a regex
       pattern doesn't match (was silently leaving stale versions).

  okhsunrog#32  Split rotate_fragments_into_history into rotate + delete steps
       so release.py can save_json + write_md *before* unlinking the
       fragment files. If anything in between fails, fragments are
       still on disk and the run is retryable.

  okhsunrog#37  codegen-interfaces.py: emit `assert!(matches_vpn(…), msg)` /
       `assert!(!matches_vpn(…), msg)` instead of
       `assert_eq!(matches_vpn(…), true/false, msg)` —
       clippy::bool_assert_comparison was firing on every generated
       row under `cargo clippy --tests`. Both generated test modules
       regenerated. CI's clippy steps now also pass `--tests` so this
       class of regression is caught.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant