diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorDaemon.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorDaemon.kt index b4fad62af1..3444e1c41c 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorDaemon.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorDaemon.kt @@ -44,24 +44,55 @@ object VectorDaemon { val scope = CoroutineScope(Dispatchers.IO + SupervisorJob() + exceptionHandler) val bridgeServiceName = "activity" - var isLateInject = false - var proxyServiceName = "serial" + /** + * Whether a `system_server` was already serving the moment this daemon started, which is how + * Zygisk injecting into a booted system rather than into a fresh fork shows up on our side. The + * boot it missed then has to be replayed, and it has to be met under another name. + * + * Set on the main thread during [main] and cleared by the death recipient on a binder thread, + * which is what the `@Volatile` is for. + */ + @Volatile var startedAfterSystemServer = false + + /** + * The name the module looks the daemon up under while system_server is specialized. `serial` is + * free at boot, because system_server registers it only much later, so a binder found there can + * only be ours. On a booted system the real service holds it, and a lookup returning that is + * indistinguishable from a successful rendezvous, so a name of our own is used instead. + * + * Computed from [startedAfterSystemServer] rather than stored beside it, so that the single + * place a restart changes its mind about the rendezvous is the place the name is read from. + */ + val proxyServiceName: String + get() = if (startedAfterSystemServer) "serial_vector" else "serial" + + /** + * The `activity` binder this daemon last linked its death recipient through. + * + * A death notification lives exactly as long as the `BinderProxy` it was registered on. Once + * [sendToBridge] returns nothing else refers to that proxy, and when ART collects it the kernel + * registration goes with it, silently: the daemon then sleeps through the next system_server + * restart, never re-claims the rendezvous, and the module that comes back finds nothing to meet. + * Holding the proxy here is what lets a daemon outlive the system_server it was started with. + * The link is dropped through this field too, so the reference that keeps the notification + * alive is the same one that releases it. + */ + @Volatile private var bridgeBinder: IBinder? = null @JvmStatic fun main(args: Array) { if (!FileSystem.tryLock()) kotlin.system.exitProcess(0) - var systemServerMaxRetry = 1 - for (arg in args) { - if (arg.startsWith("--system-server-max-retry=")) { - systemServerMaxRetry = arg.substringAfter('=').toIntOrNull() ?: 1 - } else if (arg == "--late-inject") { - isLateInject = true - proxyServiceName = "serial_vector" - } - } + // `activity` is system_server's own, registered long after the specialization in which the + // module comes looking for us, so at boot nothing answers it and a binder there means someone + // booted without us. checkService is that question by its own name: does servicemanager hold + // this one now. + startedAfterSystemServer = ServiceManager.checkService(bridgeServiceName) != null - Log.i(TAG, "Vector daemon started: lateInject=$isLateInject, proxy=$proxyServiceName") + Log.i( + TAG, + "Vector daemon started: afterSystemServer=$startedAfterSystemServer, " + + "proxy=$proxyServiceName") // The hash is here rather than in the version the manager prints: Home should stay readable, // but every saved bug report should say exactly which commit produced the daemon that wrote it. Log.i( @@ -110,7 +141,7 @@ object VectorDaemon { val isVerboseLog = ManagerService.isVerboseLogEnabled() // Setup IPC channel for applications by injecting DaemonService binder - sendToBridge(VectorService.asBinder(), false, systemServerMaxRetry) + sendToBridge(VectorService.asBinder(), false) if (!isVerboseLog) { LogcatMonitor.stopVerbose() @@ -129,11 +160,7 @@ object VectorDaemon { // The bridge is setup in `system_server` via Zygisk API @Suppress("DEPRECATION") - private fun sendToBridge( - binder: IBinder, - isRestart: Boolean, - restartRetry: Int, - ) { + private fun sendToBridge(binder: IBinder, isRestart: Boolean) { check(Looper.myLooper() == Looper.getMainLooper()) { "sendToBridge MUST run on the main thread!" } @@ -156,19 +183,29 @@ object VectorDaemon { object : IBinder.DeathRecipient { override fun binderDied() { Log.w(TAG, "System Server died! Clearing caches and re-injecting...") - bridgeService.unlinkToDeath(this, 0) + bridgeBinder?.unlinkToDeath(this, 0) + bridgeBinder = null clearSystemCaches() SystemServerService.binderDied() // Cleanup old references + // Nothing will look the invented name up again, so stop holding it. + if (startedAfterSystemServer) SystemServerService.unregisterProxyService() + // Whatever this daemon was started into, the replacement is forked by a zygote + // that already carries our hooks, so it arrives the way one does at boot: it + // looks us up under `serial`, which its predecessor has just freed by dying, + // and it broadcasts a boot of its own that there is no need to replay. + startedAfterSystemServer = false + // And the question of whether a module reached system_server is about the one + // ahead of us, not the one that died with ours inside it. + SystemServerService.systemServerRequested = false // Re-claim the service name immediately to ensure that when system_server // restarts, our proxy is already there for the Zygisk module to find. - ServiceManager.addService(proxyServiceName, SystemServerService) + SystemServerService.registerProxyService(proxyServiceName) ManagerService.guard = null // Remove dead guard - Handler(Looper.getMainLooper()).post { - sendToBridge(binder, true, restartRetry - 1) - } + Handler(Looper.getMainLooper()).post { sendToBridge(binder, true) } } } bridgeService.linkToDeath(deathRecipient, 0) + bridgeBinder = bridgeService // see [bridgeBinder]: the link is only as alive as this // Try sending the Binder payload (up to 3 times) var success = false @@ -191,9 +228,29 @@ object VectorDaemon { if (success) { Log.i(TAG, "Successfully injected Vector IPC binder for applications.") + // Only the name we invented for a late injection: `serial` is a real service name, + // and system_server registering it is what takes our proxy out of servicemanager. + if (startedAfterSystemServer) SystemServerService.unregisterProxyService() } else { - Log.e(TAG, "Failed to inject VectorService into system_server") - if (restartRetry > 0) restartSystemServer() + // system_server is not restarted here. Restarting it has never been shown to + // recover an injection: the module's hooks go in during specialization, long + // before the bridge that would be restarted over, so a handshake failing this + // late is a bug in the injection or the bootstrap. A restart hides that bug + // until someone fixes it, which is the argument against keeping one. + // + // It is not free to keep around either. Every system_server start is a boot to + // PackageWatchdog, which escalates a mitigation count that lives on disk and + // never goes down, and far enough along that ladder the mitigation stops being + // a reboot: the device is sent to recovery to be asked whether to erase itself. + // + // The two cases are still worth telling apart for whoever reads the log afterwards. + // `systemServerRequested` is latched from inside `postServerSpecialize`, so a live + // `activity` with nothing behind it means no module arrived at all. + if (!SystemServerService.systemServerRequested) { + Log.e(TAG, "No Vector module reached system_server; leaving it running") + } else { + Log.e(TAG, "Vector reached system_server but its bridge never answered") + } } } .onFailure { Log.e(TAG, "Error during injecting DaemonService", it) } @@ -235,25 +292,39 @@ object VectorDaemon { * * `system_server` is forked from the *primary* zygote, so restarting that is what restarts the * framework — on a 64/32 device the primary init service is still called `zygote` (it runs - * app_process64) and `zygote_secondary` is the 32-bit one. Restarting the secondary leaves - * system_server running, which is right for [restartSystemServer]'s own purpose and wrong for - * this one; they are separate functions for that reason. + * app_process64) and `zygote_secondary` is the 32-bit one. * - * Everything on screen dies with it. The caller is expected to have said so first. + * Everything on screen dies with it. The caller is expected to have said so first, which is + * also what tells this apart from a boot loop — see [forgetDeliberateBoot]. */ fun softReboot() { Log.w(TAG, "Soft reboot: restarting the primary zygote") + forgetDeliberateBoot() SystemProperties.set("ctl.restart", "zygote") } - fun restartSystemServer() { - Log.w(TAG, "Restarting system_server...") - val restartTarget = - if (Build.SUPPORTED_64_BIT_ABIS.isNotEmpty() && Build.SUPPORTED_32_BIT_ABIS.isNotEmpty()) { - "zygote_secondary" - } else { - "zygote" - } - SystemProperties.set("ctl.restart", restartTarget) + /** + * Takes the boot we are about to cause back out of PackageWatchdog's count. + * + * It counts every system_server start alike, having no way to tell one that was asked for from + * one a crash loop produced, and each time enough of them land close together it raises a + * mitigation count that is kept on disk and never lowered. Far enough along that ladder the + * mitigation stops being a reboot: the device goes to recovery to be asked whether to erase + * itself. A soft reboot does not clear the window count either, because init and its + * properties live through one, so deliberate restarts pile up exactly as failures would. + * + * Zeroing the window count immediately before restarting cancels the single tick we are about + * to add and nothing else. A real loop around us starts counting again from here and still + * trips, which is the point: we only vouch for the boot we asked for ourselves. + */ + private fun forgetDeliberateBoot() { + // Two names for one counter: RescueParty read `sys.rescue_boot_count` directly until the + // CrashRecovery mainline module moved it behind generated accessors and onto the + // `crashrecovery.` prefix. Both are cleared because the daemon supports both eras, and + // writing the name a platform does not use costs nothing. + for (property in arrayOf("crashrecovery.rescue_boot_count", "sys.rescue_boot_count")) { + runCatching { SystemProperties.set(property, "0") } + .onFailure { Log.w(TAG, "Could not clear `$property` before restarting", it) } + } } } diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorService.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorService.kt index 7376ac66f7..685a3664a0 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorService.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorService.kt @@ -59,8 +59,8 @@ object VectorService : IVectorDaemon.Stub() { // Initialize OS Observers using Coroutines for the dispatch blocks registerReceivers() - if (VectorDaemon.isLateInject) { - Log.i(TAG, "Late injection detected. Forcing boot completed event.") + if (VectorDaemon.startedAfterSystemServer) { + Log.i(TAG, "Started after system_server. Forcing boot completed event.") dispatchBootCompleted() } } diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/SystemServerService.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/SystemServerService.kt index 4aa4f9fb55..725a6f2a95 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/SystemServerService.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/SystemServerService.kt @@ -3,6 +3,7 @@ package org.matrix.vector.daemon.ipc import android.os.Binder import android.os.Build import android.os.IBinder +import android.os.IClientCallback import android.os.IServiceCallback import android.os.Parcel import android.os.ServiceManager @@ -27,7 +28,15 @@ private const val TAG = "VectorSystemServer" */ private object ServiceRegistrationWatcher { + // Written from the main thread at startup and from a binder thread when system_server dies. + @Volatile private var watchedName: String? = null + @Volatile private var unusedName: String? = null + fun watch(serviceName: String) { + // servicemanager keeps every callback it is handed, and a system_server restart brings the + // daemon back through here; without this a long-lived daemon stacks up duplicates that all + // report the same registration. + if (watchedName == serviceName) return val callback = object : IServiceCallback.Stub() { // The IServiceCallback will tell us when the real Android service is ready, @@ -41,8 +50,40 @@ private object ServiceRegistrationWatcher { override fun asBinder(): IBinder = this } runCatching { getSystemServiceManager().registerForNotifications(serviceName, callback) } + .onSuccess { watchedName = serviceName } .onFailure { Log.e(TAG, "Failed to register IServiceCallback", it) } } + + /** + * Asks servicemanager to drop [serviceName] once nothing but itself is holding [binder]. + * + * It will not drop a service on request alone. `tryUnregisterService` refuses outright while + * the service is marked as having a client on the way, which handing the binder out is what + * sets, and the only code that clears that mark returns early for any name without a client + * callback — so for a service registered the ordinary way the mark is permanent and the + * refusal is forever. Asking repeatedly changes nothing. + * + * Registering a callback is what puts the name on servicemanager's interval, which then clears + * the mark and, once the last client is gone, says so. Unregistering from inside that is how a + * lazy service shuts itself down, and it is the only sequence servicemanager accepts. + */ + fun unregisterWhenUnused(serviceName: String, binder: IBinder) { + if (unusedName == serviceName) return + val callback = + object : IClientCallback.Stub() { + override fun onClients(registered: IBinder, hasClients: Boolean) { + if (hasClients) return + runCatching { getSystemServiceManager().tryUnregisterService(serviceName, registered) } + .onSuccess { Log.d(TAG, "Dropped the proxy service `$serviceName`") } + .onFailure { Log.d(TAG, "Proxy service `$serviceName` stays registered: $it") } + } + + override fun asBinder(): IBinder = this + } + runCatching { getSystemServiceManager().registerClientCallback(serviceName, binder, callback) } + .onSuccess { unusedName = serviceName } + .onFailure { Log.d(TAG, "Proxy service `$serviceName` stays registered: $it") } + } } /** @@ -59,7 +100,9 @@ object SystemServerService : Binder(), IBinder.DeathRecipient { private var proxyServiceName: String? = null private var originService: IBinder? = null - var systemServerRequested = false + // Latched from a binder thread when system_server attaches, cleared from another when it dies, + // and read on the daemon's main thread. + @Volatile var systemServerRequested = false fun registerProxyService(serviceName: String) { // Register as the service name early to setup an IPC for `system_server`. @@ -80,6 +123,20 @@ object SystemServerService : Binder(), IBinder.DeathRecipient { .onFailure { Log.e(TAG, "Failed to register proxy service `$serviceName`", it) } } + /** + * Arranges for our proxy to leave servicemanager once the module it was there for is done with + * it; see [ServiceRegistrationWatcher.unregisterWhenUnused] for why it cannot simply be asked. + * + * Worth arranging because the name invented for a late injection is ours alone, so unlike + * `serial` nothing will ever come along and replace it. Android R and later; older + * servicemanagers offer no way to take a name back, and there it stays registered. + */ + fun unregisterProxyService() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return + val name = proxyServiceName ?: return + ServiceRegistrationWatcher.unregisterWhenUnused(name, this) + } + /** * Adopts the real system service once servicemanager reports its registration, so that * [onTransact] starts forwarding to it. Only ever called from [ServiceRegistrationWatcher]. diff --git a/hiddenapi/stubs/src/main/java/android/os/IClientCallback.java b/hiddenapi/stubs/src/main/java/android/os/IClientCallback.java new file mode 100644 index 0000000000..5993c200b3 --- /dev/null +++ b/hiddenapi/stubs/src/main/java/android/os/IClientCallback.java @@ -0,0 +1,14 @@ +package android.os; +public interface IClientCallback extends IInterface +{ + public static abstract class Stub extends android.os.Binder implements android.os.IClientCallback { + } + /** + * Called when the number of clients of a registered service crosses zero in either + * direction, as servicemanager sees it on its own interval. + * + * @param registered the binder the callback was registered with + * @param hasClients whether anything other than servicemanager holds it + */ + public void onClients(android.os.IBinder registered, boolean hasClients) throws android.os.RemoteException; +} diff --git a/hiddenapi/stubs/src/main/java/android/os/IServiceManager.java b/hiddenapi/stubs/src/main/java/android/os/IServiceManager.java index 1e1193ee96..fa8ac1e307 100644 --- a/hiddenapi/stubs/src/main/java/android/os/IServiceManager.java +++ b/hiddenapi/stubs/src/main/java/android/os/IServiceManager.java @@ -6,6 +6,10 @@ public interface IServiceManager extends IInterface { public void registerForNotifications(String name, IServiceCallback cb); + public void tryUnregisterService(String name, IBinder service); + + public void registerClientCallback(String name, IBinder service, IClientCallback cb); + abstract class Stub extends Binder implements IServiceManager { public static IServiceManager asInterface(IBinder obj) { throw new UnsupportedOperationException(); diff --git a/hiddenapi/stubs/src/main/java/android/os/ServiceManager.java b/hiddenapi/stubs/src/main/java/android/os/ServiceManager.java index bf1a9f21a6..59b95e5650 100644 --- a/hiddenapi/stubs/src/main/java/android/os/ServiceManager.java +++ b/hiddenapi/stubs/src/main/java/android/os/ServiceManager.java @@ -15,6 +15,17 @@ public static IBinder getService(String name) { throw new RuntimeException("STUB"); } + /** + * Returns a reference to a service with the given name if one is already registered. + * + * @param name the name of the service to get + * @return a reference to the service, or null if the service doesn't exist + */ + @Nullable + public static IBinder checkService(String name) { + throw new RuntimeException("STUB"); + } + /** * Place a new @a service called @a name into the service * manager. diff --git a/zygisk/README.md b/zygisk/README.md index 0da0126d67..f519ded72f 100644 --- a/zygisk/README.md +++ b/zygisk/README.md @@ -19,11 +19,25 @@ When a transaction occurs, the hook inspects the transaction code. If it matches ### Phase 1: System Server Initialization The `system_server` acts as the primary proxy router for the framework. During the `postServerSpecialize` callback, the following sequence occurs: -1. The native module queries `ServiceManager` for the `serial` service (or `serial_vector` for late-inject scenarios). This service acts as a temporary rendezvous point. +1. The native module queries `ServiceManager` for the rendezvous service, `serial` or `serial_vector` depending on whether a `system_server` was already running (see _Choosing the rendezvous name_ below). 2. The module sends a `_VEC` transaction to retrieve a temporary binder, which it uses to fetch the framework DEX file descriptor and the obfuscation map. 3. The module installs the JNI Binder Trap (`HookBridge`) and bootstraps the Kotlin layer via `Main.forkCommon`. 4. Concurrently, the root daemon initiates a Binder transaction directly to the `system_server`. The JNI trap intercepts this, and BridgeService processes the `SEND_BINDER` action, storing the daemon's primary `IVectorDaemon` binder, sending back `system_server` context and linking a `DeathRecipient`. +#### Choosing the rendezvous name +`serial` is a real `system_server` service, registered long after specialization. At boot it is therefore free, and a binder found under it can only be the daemon's proxy. When Zygisk instead attaches to a `system_server` that booted without Vector, the real service is already registered, and a lookup returning it would be indistinguishable from a successful rendezvous; `serial_vector`, a name no platform service uses, is claimed for that case. + +The late case is NeoZygisk's late load — `ksud late-load`, a manual trigger, or a root session re-applied to a system that booted without one — which attaches to a `system_server` already serving and drives `postServerSpecialize` on a thread of its own ([NeoZygisk#174](https://github.com/JingMatrix/NeoZygisk/pull/174)). It says so through the `LATE_INJECT` runtime flag, and the module reads that first. + +Nothing tells the daemon, which the same `service.sh` starts either way, so it asks the one question both sides can answer alike: does `activity` already answer? Nothing has registered it during a boot, and a `system_server` that is serving has. The module falls back on the same probe wherever the flag is absent. At boot the two cannot disagree: the module's poll for the rendezvous runs on `system_server`'s own thread during specialization, so `activity` cannot appear while a daemon is still starting up behind it. + +`serial_vector` is the daemon's alone, so unlike `serial` nothing will ever come along and replace it. Once the module it existed for has let go of the binder, the daemon unregisters it through `registerClientCallback`, which is the only sequence servicemanager accepts — `tryUnregisterService` on its own is refused for as long as the service is marked as having a client, and for a name without a client callback that mark is never cleared. + +#### When `system_server` restarts +A soft reboot, a crash or `ctl.restart zygote` leaves the daemon running and replaces the process it was talking to. The `DeathRecipient` the daemon linked on the `activity` binder is what notices, which is why that binder is retained for as long as the link is meant to last: the notification lives exactly as long as the `BinderProxy` it was registered on. + +The replacement is forked by a zygote that already carries Vector's hooks, so it arrives exactly as one does at boot and looks the daemon up under `serial`, which its predecessor freed by dying. The daemon re-claims `serial` on the death notification and stops treating itself as late-injected: the replacement broadcasts a boot of its own, so there is nothing to replay. + ### Phase 2: User Application Rendezvous Standard applications initialize their IPC connection by routing requests through the `system_server`. 1. In `postAppSpecialize`, the application queries `ServiceManager` for the `activity` service (which resides in `system_server`). diff --git a/zygisk/module/sepolicy.rule b/zygisk/module/sepolicy.rule index e27ad3a052..8c96645da4 100644 --- a/zygisk/module/sepolicy.rule +++ b/zygisk/module/sepolicy.rule @@ -13,3 +13,6 @@ allow zygote dex2oat unix_stream_socket * type xposed_data file_type typeattribute xposed_data mlstrustedobject allow * xposed_data {file dir} * + +# `serial_vector` is in no service_contexts, so it is labelled default_android_service +allow system_server default_android_service service_manager find diff --git a/zygisk/module/service.sh b/zygisk/module/service.sh index e49f8859b2..cc00edf414 100644 --- a/zygisk/module/service.sh +++ b/zygisk/module/service.sh @@ -2,5 +2,12 @@ MODDIR="${0%/*}" cd "$MODDIR" || exit 1 -# Start the daemon directly in the background within a private mount namespace -unshare --propagation slave -m "$MODDIR/daemon" --system-server-max-retry=3 "$@" & +# A root manager runs this script with its own busybox, but a manual bootstrap has nothing in +# front of it except Android's utilities, so this is written for toybox's unshare: it makes a +# mount namespace with -m and takes no propagation flag. The namespace therefore marks its own +# root slave before handing over, which is what keeps the daemon's dex2oat bind-mounts out of +# the global one. +unshare -m /system/bin/sh -c ' + mount -o rslave none / || echo "vector: this mount namespace stays shared" >&2 + exec "$0" "$@" +' "$MODDIR/daemon" "$@" & diff --git a/zygisk/src/main/cpp/include/ipc_bridge.h b/zygisk/src/main/cpp/include/ipc_bridge.h index 7488406710..d3f6cec0bc 100644 --- a/zygisk/src/main/cpp/include/ipc_bridge.h +++ b/zygisk/src/main/cpp/include/ipc_bridge.h @@ -46,6 +46,13 @@ class IPCBridge { */ lsplant::ScopedLocalRef RequestAppBinder(JNIEnv *env, jstring nice_name); + /** + * @brief Tells whether a system_server is already serving binder calls. + * @param env JNI environment pointer. + * @return True if the rendezvous service of running applications answers. + */ + bool IsSystemServerRunning(JNIEnv *env); + /** * @brief Requests the system_server's dedicated Binder from the host service. * @param env JNI environment pointer. @@ -127,6 +134,7 @@ class IPCBridge { // android.os.ServiceManager jclass service_manager_class_ = nullptr; jmethodID get_service_method_ = nullptr; + jmethodID check_service_method_ = nullptr; // android.os.IBinder jmethodID transact_method_ = nullptr; diff --git a/zygisk/src/main/cpp/ipc_bridge.cpp b/zygisk/src/main/cpp/ipc_bridge.cpp index bc19adfd8a..2cd6bf8089 100644 --- a/zygisk/src/main/cpp/ipc_bridge.cpp +++ b/zygisk/src/main/cpp/ipc_bridge.cpp @@ -157,6 +157,13 @@ void IPCBridge::Initialize(JNIEnv *env) { LOGE("IPCBridge: ServiceManager.getService method not found!"); return; } + // IsSystemServerRunning needs it: checkService asks servicemanager whether it holds the + // name now. Its absence costs only the probe, leaving LATE_INJECT to answer on its own. + check_service_method_ = lsplant::JNI_GetStaticMethodID( + env, service_manager_class_, "checkService", "(Ljava/lang/String;)Landroid/os/IBinder;"); + if (!check_service_method_) { + LOGW("IPCBridge: ServiceManager.checkService method not found."); + } // IBinder auto ibinder_class = lsplant::ScopedLocalRef(env, env->FindClass("android/os/IBinder")); @@ -297,6 +304,19 @@ lsplant::ScopedLocalRef IPCBridge::RequestAppBinder(JNIEnv *env, jstrin return result_binder; } +bool IPCBridge::IsSystemServerRunning(JNIEnv *env) { + if (!initialized_ || !check_service_method_) { + return false; + } + + // kBridgeServiceName is published by system_server well after it is specialized, so nothing + // answers at boot and the live service answers once it has booted without us. + auto service_name = lsplant::ScopedLocalRef(env, env->NewStringUTF(kBridgeServiceName.data())); + auto binder = lsplant::JNI_CallStaticObjectMethod(env, service_manager_class_, + check_service_method_, service_name.get()); + return binder.get() != nullptr; +} + lsplant::ScopedLocalRef IPCBridge::RequestSystemServerBinder( JNIEnv *env, std::string bridgeServiceName) { if (!initialized_) { diff --git a/zygisk/src/main/cpp/module.cpp b/zygisk/src/main/cpp/module.cpp index 84ff88312d..001e85c3da 100644 --- a/zygisk/src/main/cpp/module.cpp +++ b/zygisk/src/main/cpp/module.cpp @@ -415,9 +415,14 @@ void VectorModule::postServerSpecialize(const zygisk::ServerSpecializeArgs *args // --- Framework Injection for System Server --- auto &ipc_bridge = IPCBridge::GetInstance(); - std::string bridgeServiceName = "serial"; - bool is_late_inject = (args->runtime_flags & RuntimeFlags::LATE_INJECT) != 0; - if (is_late_inject) bridgeServiceName = "serial_vector"; + // NeoZygisk sets LATE_INJECT when it drove this specialization on a system_server that was + // already serving. An implementation that says nothing is caught by the probe: a serving + // system_server is what a late load always leaves behind, and what the daemon tests too. + bool is_late_inject = (args->runtime_flags & RuntimeFlags::LATE_INJECT) != 0 || + ipc_bridge.IsSystemServerRunning(env_); + // The real `serial` service is registered by then, and a lookup returning it would be + // indistinguishable from a successful rendezvous. + std::string bridgeServiceName = is_late_inject ? "serial_vector" : "serial"; auto system_binder = ipc_bridge.RequestSystemServerBinder(env_, bridgeServiceName); if (!system_binder) { LOGE("Failed to get system server IPC binder. Aborting injection."); diff --git a/zygisk/src/main/kotlin/org/matrix/vector/core/Main.kt b/zygisk/src/main/kotlin/org/matrix/vector/core/Main.kt index f41c2a6662..69629a7bff 100644 --- a/zygisk/src/main/kotlin/org/matrix/vector/core/Main.kt +++ b/zygisk/src/main/kotlin/org/matrix/vector/core/Main.kt @@ -19,7 +19,9 @@ object Main { * Shared initialization logic for both System Server and Application processes. * * @param isSystem True if this is the system_server process. - * @param isLateInject True if Zygisk APIs are not invoked via hooks + * @param isLateInject True if Zygisk injected us into a system_server that was already + * running, which is past the points the hooks would have caught, so the startup they drive + * is run by hand instead. * @param niceName The process name (e.g., package name or "system"). * @param appDir The application's data directory. * @param binder The Binder token associated with the application service.