Debugging the LSPosed (Java-layer) hooks

September 16, 2026 · View on GitHub

How to tell whether the LSPosed module's system_server hooks actually attached, how to read the install telemetry, and how to diagnose "some apps still detect my VPN" reports that come down to a hook not attaching.

This is the Java-layer counterpart to diagnostics.md (the self-test model), detection-vectors.md (which backend covers which vector), and lsposed/AGENTS.md (the Kotlin module architecture). The wire the state file speaks is in protocol.md.

The hooks live in lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/hook/HookEntry.kt; the state file they publish is written by LsposedState.kt and rendered by HookDiagnostics.kt.

1. Two hook families (why some checks pass while others leak)

The module installs two very different kinds of hook inside system_server, and they fail independently — so a half-broken Java layer is normal to see:

  • writeToParcel sanitizers on NetworkCapabilities, LinkProperties, NetworkInfo. These are framework classes on the boot classpath, always resolvable, so they attach trivially and rarely break. They strip VPN data as the object is serialized to a target UID.
  • ConnectivityService method hooks — hooks on the actual service methods (getActiveNetwork, getAllNetworks, getNetworkForType, getNetworkInfo, the callback dispatchers). These replace/blank the network handle identity and legacy-type answers. On Android 13+ the class lives in the Connectivity APEX, so these can only be hooked via the service binder's classloader — this is the fragile family (see §8).

Which diagnostics check leans on which family:

CheckFamilyNotes
hasTransport(VPN), hasCapability(NOT_VPN), getTransportInfo1capabilities
getAllNetworks() VPN scan1capabilities of each net
LinkProperties ifname / routes1link properties
ActiveNetwork transports1capabilities of the active net
getNetworkForType(TYPE_VPN)2legacy type → handle
ActiveNetwork handle2netId identity
getAllNetworks() handles2netId identity
NetworkCallback (push)2callback dispatch
getNetworkInfo(TYPE_VPN)2legacy type query

The tell-tale signature of "family 2 didn't attach" is that ActiveNetwork transports passes but ActiveNetwork handle fails on the same active network: the returned handle is still the VPN's netId, yet its capabilities come back clean. Family 1 sanitized the capabilities; family 2 never swapped the handle.

Callback payload construction and delivery are separated in NetworkCallbackRouter. On services with the Bundle dispatcher, the outer builder and frozen-receiver queue keep the original VPN identity; rewriting happens only at final delivery. The router asks the service to build recipient-redacted cover payloads, tracks one visible best handle per registration, and maps loss to that delivered handle. LISTEN_FOR_BEST uses that lifecycle; only an ordinary LISTEN suppresses VPN matches without replacement. On older single-dispatch services, LOST is sent using the platform's NetworkRequest/Network message shape. PendingIntents retain a separate NAI adapter because their extras are parcelled asynchronously.

2. Where the truth lives: /data/system/vpnhide_lsposed_state

The module publishes a small text "control channel" from system_server; the app reads it. It survives reboots and is independent of logcat and the debug-logging toggle. Three sections:

vpnhide 1 status        # backend id, kernel ver, hooks bitmask, error code
backend 0x3
kver 0x0
hooks 0x3fc00
error 0x0
meta <key> <value>      # one line per metadata key
...
vpnhide 1 stats         # per-uid per-hook fire counters
0x27fe 0xa:0x1 0xb:0x1 0xd:0x1 0xe:0x1 0x10:0xc

It reaches a debug bundle two ways (the bundle is a single state.json packed in the export zip; the state file is read as root into the snapshot):

  • the hookReport field — parsed and rendered (status mask, metadata: block, installed/missing hooks, counter deltas). This is what you normally read.
  • the raw file verbatim, in the sections.lsposed_state entry.

Because the read is root-backed and the file is always written, cs_* telemetry lands in the bundle even with debug logging off. Debug logging only enriches the logcat portions of the bundle.

3. Reading the hookReport field (the LSPOSED section)

LSPOSED:
  status.hooks=0x3fc00
  status.error=OK(0)
  installed hooks (8/8):
    [10] lsposed_link_properties ...
  metadata:
    cs_path=C
    cs_network=getActiveNetwork=1,getAllNetworks=1,getNetworkForType=1
    ...

The mask is honest: the three connectivity bits are set only once those hooks actually attach (see §8). A device where they don't shows status.error=PARTIAL_HOOKS, installed hooks (5/8), and lists them under missing owned hooks — not a false "8/8 installed".

Hook IDs (bit = id):

idhexnamefamilycovers
100x400lsposed_link_properties1ifname / routes / DNS
110x800lsposed_network_capabilities1transports / NOT_VPN
120x1000lsposed_network_info1legacy NetworkInfo type
130x2000lsposed_network2*Network parcel replacement
140x4000lsposed_connectivity_result2getNetworkInfo/LP/NC results
150x8000lsposed_connectivity_callback2push callbacks
160x10000lsposed_connectivity_network2getActiveNetwork/AllNetworks/ForType
170x20000lsposed_package_visibilityapp-hiding from PackageManager

0x3fc00 = all of 10–17. The connectivity family is 14|15|16 = 0x1c000. (*13 depends on the CS instance being captured, so it dies with family 2.)

The stats counters show which hooks actually fired for the target app's uid. On a healthy device you see 0xe (result), 0x10 (network), 0xd (Network parcel) among them; their absence is the first hint family 2 is down.

4. The cs_* attach telemetry

ConnectivityService attaches asynchronously and by several paths, so the outcome is recorded into cs_* meta keys as it happens:

keymeaning
cs_attemptsfull trail: install … | A:… | B:… | C:… | D:… — every path's outcome
cs_pathwhich path finally attached (A/B/C/D); absent if none did
cs_classresolved class (com.android.server.ConnectivityService)
cs_loaderclassloader chain, e.g. PathClassLoader<PathClassLoader<BootClassLoader (double = APEX) vs single PathClassLoader<BootClassLoader (system_server)
cs_ctorconstructors hooked
cs_resultper-method match counts for synchronous results (getNetworkCapabilities, getNetworkInfo, …)
cs_networkgetActiveNetwork / getAllNetworks / getNetworkForType counts
cs_callbackcallCallbackForRequest / sendPendingIntentForRequest counts

Known-good fingerprint (Android 13+, attach via the binder classloader):

cs_path      C            (or D)
cs_loader    PathClassLoader<PathClassLoader<BootClassLoader
cs_network   getActiveNetwork=1,getAllNetworks=1,getNetworkForType=1
cs_callback  callCallbackForRequest=1,sendPendingIntentForRequest=1

A:notReady(ClassNotFound com.android.server.ConnectivityService) in the trail is normal on A13+ — path A can't see the APEX class, so it hands off to the binder-classloader paths.

5. Interpreting a failing report

Read cs_attempts top to bottom and match the divergence from §4:

Symptom in cs_*Meaning
cs_path absent; trail ends … | B:getService=null with no C/D attachHooks never attached — the service was never resolved.
Trail has no C:addService(connectivity) seenThe ROM doesn't publish connectivity through the hooked ServiceManager.addService (seen on MediaTek/OEM). Path D (deferred getService) is the fallback that covers this.
cs_network has getNetworkForType=0 (etc.)That method name/signature differs on the ROM; hookAllMethods matched nothing.
cs_path set, cs_network all =1, mask full, but the connectivity stats counters never move and checks leakHooks bound but never fire. Two known causes, both fixed (§8): an early pre-construction attach ART discarded (the install-time classloader path, removed), or a name-resolved class that isn't the live instance — look for nameResolvesSame=false in cs_attempts (now we hook binder.javaClass directly).

6. Why logcat is not the tool here

The install runs at early boot with debug logging off (the flag is read that early and defaults false), so the HookLog.i install lines are never emitted; and even when emitted they are gone by report time (ring-buffer rotation). This was confirmed on a working device: debug on, zero install lines in logcat, yet hooks attached. Always use the state file / the hookReport field, not logcat, to answer "did the hooks attach".

7. Collecting a report (users / testers)

Prerequisites:

  • Root granted to the app (the state file is read via root).
  • The VpnHide module enabled with "System Framework" scope in your Xposed manager (it hooks system_server, nothing else).
  • An active VPN — the leak checks only run against a live tunnel.

Steps:

  1. Reboot (so a fresh attach writes current cs_* into the state file).
  2. Open VPN Hide once with the VPN connected (its checks run at cold start).
  3. Diagnostics → Collect debug logs → send the zip.

Debug logging does not need to be on for cs_* — it only adds the logcat sections. Open the bundle's hookReport field and read §3–§5.

8. The attach mechanism (developers)

HookEntry.installConnectivityServiceHook only ever attaches from the live service binder, never via the raw system_server classloader. A classloader attach at install time runs before ConnectivityService is constructed; ART then replaces the method entries during class init/compile and the hooks silently never fire — observed on MediaTek A11, where every method matched and the mask read full yet no connectivity counter ever moved. It tries, in order:

  • BgetService("connectivity") now, hook from its binder's classloader. Usually null at install time (service not registered yet).
  • C — hook ServiceManager.addService to catch the registration and take the binder's classloader. Fast when it fires — but only fires if the ROM actually publishes through that Java method.
  • D — deferred fallback: poll getService("connectivity") on a short-lived HandlerThread (500 ms, ~90 s budget) until the live binder appears, then attach. Independent of how the service was registered; this is what covers ROMs where C never fires. The thread is torn down (quitSafely) the moment any path attaches or the budget runs out.

All three attach late, to an already-constructed instance — which is why the hooks stick (an install-time classloader attach does not).

Two facts make B/C/D work:

  1. Inside system_server, getService/the addService argument return the local ConnectivityService instance, not a BinderProxy, so binder.javaClass is the live class.
  2. We hook binder.javaClass directly, never a name-resolved class. Resolving com.android.server.ConnectivityService by name through the binder's classloader can, on a child-loader ROM (MediaTek A11), follow delegation to a parent copy of the class — a different Class object with the same name. Hooking that copy attaches cleanly (all methods match, mask full) but never fires, because the live binder dispatches to the child copy. The nameResolvesSame flag in cs_attempts records whether name-resolution would have returned that same live class (true on Pixels; false is the trap).

The bits and cs_* are reported from reportConnectivityAttachLsposedStats.setConnectivityDiagnostics, which folds the bits into the mask and the meta into the state file. To add a new signal, record it there — every meta key is rendered by HookDiagnostics.kt automatically, no reader change needed.

Note on module state after updating the APK: normally an Xposed manager keeps a module enabled (with its scope) across an in-place update. If after installing a new build the module shows disabled or unscoped, that is a manager/device quirk — just re-enable it with System Framework scope; it is not expected behavior and nothing in VpnHide changes it.

9. The network-view probe (consistency, post-Binder)

The hooks rewrite several objects independently (handle, capabilities, link properties, NetworkInfo, callback payloads), so the only measurement that counts is what an app receives after Binder, all of it together. That is the network view: every handle the uid can see, the facts each handle answers, the legacy type answers, every callback delivered during a capture window, and a set of invariants evaluated over the whole (NetworkViewData.kt):

InvariantHolds when
active_in_all_networksthe active handle is listed by getAllNetworks()
listed_networks_have_transportno listed network answers a transport-less capability set
connected_network_has_interfacea CONNECTED network's link properties name an interface
info_type_matches_transportNetworkInfo.type names a transport the handle actually has
active_info_matches_active_networkgetActiveNetworkInfo() describes the active handle
no_phantom_networksno netId outside getAllNetworks() answers a blind Network(netId) query
callback_matches_sync_viewcallback payloads for a handle equal the synchronous answers for it
callbacks_only_for_listed_networks / pending_intent_only_listed_networkspushes never name an unlisted handle (the PendingIntent pair is a listen and an INTERNET request, both without NOT_VPN; inside the VPN the request is satisfied by the VPN itself)
no_vpn_transport, legacy_vpn_inactive, vpn_listen_silent(targets only) no VPN transport, handle or legacy state anywhere

Two captures share the code:

  • The debug bundle carries the app's own view under networkView (forensics on), PendingIntent path included.

  • Any uidscripts/network-view-probe.py runs the same capture through root app_process under the uid of a given package (the installed APK is the classpath), so a target and a non-target app can be diffed on one device:

    uv run scripts/network-view-probe.py snapshot --package org.example.plain -o baseline.json
    uv run scripts/network-view-probe.py snapshot --package com.example.bank --expect-hidden -o target.json
    uv run scripts/network-view-probe.py compare baseline.json target.json
    

    compare expects the target's allNetworks to be the baseline minus the VPN handles, every shared handle's facts to be identical, the active handle to be replaced only when the baseline's was the VPN, and no violated invariant. Take both snapshots on a stable connection; across a Wi-Fi/mobile switch the two captures legitimately straddle a change — compare timestamps before calling a difference a defect.

    Two platform effects to keep out of the verdict: the probe runs as a background uid, so on Android 15+ the per-uid background firewall makes every NetworkInfo read BLOCKED/DISCONNECTED and getActiveNetwork() null for a blocked uid — pick baseline and target in the same state, and read blocked callback flags as the platform's word, not ours. And whether a uid is inside the VPN decides what Android itself hands it (its default network is the VPN, listens match the VPN) — a target inside and one outside the tunnel are different scenarios, not one bug and one fix.