From 8f9d34d3c9096c84f85395d5bc76c7e9ed6217f0 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Sun, 12 Apr 2026 11:29:37 +0200 Subject: [PATCH 1/3] Fix Binder IPC side-channel detection in CallBooleanMethodV hook This side-channel attack is obvious from the repeating logs: An isolated service (`com.reveny.nativecheck.app.isolated.IsolatedService`) intentionally spams Binder transactions to trigger our IPC hook. In the previous implementation, if a transaction failed, the caller's ID was stored in `g_last_failed_id`. However, the state was immediately cleared on the caller's next transaction. This created a predictable, alternating loop (Intercept -> Fail -> Bypass/Clear -> Intercept) that allowed the isolated process to detect the presence of the hook via timing/behavioral observation. We fix the flaw by keeping the failing caller in a persistent bypassed state. `g_last_failed_id` is now only reset when a different caller attempts a transaction. This effectively breaks the loop and silences the side-channel leak against continuous transaction spam. Additionally, this commit includes minor fixes discovered during debugging: - module.cpp: Fix invalid fmt placeholder (`%d` -> `{}`) in isolated process log. - ManagerService.kt: Fix logical order to save verbose logging preference before applying the LogcatMonitor state. --- .../kotlin/org/matrix/vector/daemon/ipc/ManagerService.kt | 2 +- zygisk/src/main/cpp/ipc_bridge.cpp | 5 +++-- zygisk/src/main/cpp/module.cpp | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ManagerService.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ManagerService.kt index 5aefcb6c0..412165747 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ManagerService.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ManagerService.kt @@ -239,8 +239,8 @@ object ManagerService : ILSPManagerService.Stub() { override fun isVerboseLog() = PreferenceStore.isVerboseLogEnabled() || BuildConfig.DEBUG override fun setVerboseLog(enabled: Boolean) { - if (isVerboseLog()) LogcatMonitor.startVerbose() else LogcatMonitor.stopVerbose() PreferenceStore.setVerboseLog(enabled) + if (isVerboseLog()) LogcatMonitor.startVerbose() else LogcatMonitor.stopVerbose() } override fun getVerboseLog() = diff --git a/zygisk/src/main/cpp/ipc_bridge.cpp b/zygisk/src/main/cpp/ipc_bridge.cpp index b4e228068..622b03fde 100644 --- a/zygisk/src/main/cpp/ipc_bridge.cpp +++ b/zygisk/src/main/cpp/ipc_bridge.cpp @@ -492,9 +492,10 @@ jboolean JNICALL IPCBridge::CallBooleanMethodV_Hook(JNIEnv *env, jobject obj, jm // If this caller is the one that just failed, // skip interception and go straight to the original function. if (current_caller_id == last_failed) { - // We "consume" the failed state by resetting it, so the *next* call is not skipped. - g_last_failed_id.store(~0, std::memory_order_relaxed); return GetInstance().call_boolean_method_v_backup_(env, obj, methodId, args); + } else if (last_failed != ~0) { + // Consume the failed state by resetting it, so the next call is not skipped. + g_last_failed_id.store(~0, std::memory_order_relaxed); } } diff --git a/zygisk/src/main/cpp/module.cpp b/zygisk/src/main/cpp/module.cpp index d4c303f6c..1692239a0 100644 --- a/zygisk/src/main/cpp/module.cpp +++ b/zygisk/src/main/cpp/module.cpp @@ -288,7 +288,7 @@ void VectorModule::preAppSpecialize(zygisk::AppSpecializeArgs *args) { if ((app_id >= FIRST_ISOLATED_UID && app_id <= LAST_ISOLATED_UID) || (app_id >= FIRST_APP_ZYGOTE_ISOLATED_UID && app_id <= LAST_APP_ZYGOTE_ISOLATED_UID) || app_id == SHARED_RELRO_UID) { - LOGV("Skipping injection for '{}': is an isolated process (UID: %d).", nice_name_str.get(), + LOGV("Skipping injection for '{}': is an isolated process (UID: {}).", nice_name_str.get(), app_id); return; } From d6f0cb0e264afa0c08979f1c38c823accbaa2f8a Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Sun, 12 Apr 2026 18:41:56 +0200 Subject: [PATCH 2/3] Improve logic for resetting g_last_failed_id We only explicitly reset it to ~0 when the brigde approves the last connection. --- zygisk/src/main/cpp/ipc_bridge.cpp | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/zygisk/src/main/cpp/ipc_bridge.cpp b/zygisk/src/main/cpp/ipc_bridge.cpp index 622b03fde..37f827f3a 100644 --- a/zygisk/src/main/cpp/ipc_bridge.cpp +++ b/zygisk/src/main/cpp/ipc_bridge.cpp @@ -476,8 +476,11 @@ jboolean IPCBridge::ExecTransact_Replace(jboolean *res, JNIEnv *env, jobject obj if (*res == JNI_FALSE) { uint64_t caller_id = BinderCaller::GetId(); if (caller_id != 0) { + // LOGV("Caller {} rejected by bridge service.", caller_id); g_last_failed_id.store(caller_id, std::memory_order_relaxed); } + } else { + g_last_failed_id.store(~0, std::memory_order_relaxed); } return true; // Return true to indicate we handled the call. } @@ -486,21 +489,18 @@ jboolean IPCBridge::ExecTransact_Replace(jboolean *res, JNIEnv *env, jobject obj jboolean JNICALL IPCBridge::CallBooleanMethodV_Hook(JNIEnv *env, jobject obj, jmethodID methodId, va_list args) { - uint64_t current_caller_id = BinderCaller::GetId(); - if (current_caller_id != 0) { - uint64_t last_failed = g_last_failed_id.load(std::memory_order_relaxed); - // If this caller is the one that just failed, - // skip interception and go straight to the original function. - if (current_caller_id == last_failed) { + // Check if the method being called is the one we want to intercept: Binder.execTransact() + if (methodId == GetInstance().exec_transact_backup_method_id_) { + uint64_t current_caller_id = BinderCaller::GetId(); + + if (current_caller_id != 0 && + current_caller_id == g_last_failed_id.load(std::memory_order_relaxed)) { + // If this caller is the one that just failed, + // skip interception and go straight to the original function. + // LOGV("Skip caller {} for bridge service.", current_caller_id); return GetInstance().call_boolean_method_v_backup_(env, obj, methodId, args); - } else if (last_failed != ~0) { - // Consume the failed state by resetting it, so the next call is not skipped. - g_last_failed_id.store(~0, std::memory_order_relaxed); } - } - // Check if the method being called is the one we want to intercept: Binder.execTransact() - if (methodId == GetInstance().exec_transact_backup_method_id_) { jboolean res = false; // Attempt to handle the transaction with our replacement logic. if (ExecTransact_Replace(&res, env, obj, args)) { From ae29207391703d7dc3152e6591078636d16de403 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Sun, 12 Apr 2026 19:23:55 +0200 Subject: [PATCH 3/3] [skip ci] simplify if condition --- zygisk/src/main/cpp/ipc_bridge.cpp | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/zygisk/src/main/cpp/ipc_bridge.cpp b/zygisk/src/main/cpp/ipc_bridge.cpp index 37f827f3a..71217c6d4 100644 --- a/zygisk/src/main/cpp/ipc_bridge.cpp +++ b/zygisk/src/main/cpp/ipc_bridge.cpp @@ -493,17 +493,13 @@ jboolean JNICALL IPCBridge::CallBooleanMethodV_Hook(JNIEnv *env, jobject obj, jm if (methodId == GetInstance().exec_transact_backup_method_id_) { uint64_t current_caller_id = BinderCaller::GetId(); + jboolean res = false; + // Attempt to handle the transaction with our replacement logic. if (current_caller_id != 0 && - current_caller_id == g_last_failed_id.load(std::memory_order_relaxed)) { // If this caller is the one that just failed, // skip interception and go straight to the original function. - // LOGV("Skip caller {} for bridge service.", current_caller_id); - return GetInstance().call_boolean_method_v_backup_(env, obj, methodId, args); - } - - jboolean res = false; - // Attempt to handle the transaction with our replacement logic. - if (ExecTransact_Replace(&res, env, obj, args)) { + current_caller_id != g_last_failed_id.load(std::memory_order_relaxed) && + ExecTransact_Replace(&res, env, obj, args)) { return res; // If we handled it, return the result directly. } // If not handled, fall through to call the original method.