diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/InlineHookProcessPolicy.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/InlineHookProcessPolicy.kt new file mode 100644 index 000000000..4334f3847 --- /dev/null +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/InlineHookProcessPolicy.kt @@ -0,0 +1,28 @@ +package org.matrix.vector.daemon.data + +import android.os.Process + +/** Pure process matching rules shared by the daemon policy and local unit tests. */ +object InlineHookProcessPolicy { + fun matchesSystemUiVirtualPackage( + configuredPackages: Set, + processName: String, + uid: Int + ): Boolean = + SYSTEM_UI_VIRTUAL_PACKAGE in configuredPackages && + uid == Process.SYSTEM_UID && + processName == SYSTEM_UI_PROCESS + + fun matchesPackage( + expectedUid: Int, + actualUid: Int, + processName: String, + applicationProcessName: String?, + componentProcesses: Set + ): Boolean = + expectedUid == actualUid && + (processName == applicationProcessName || processName in componentProcesses) + + fun mayInvalidate(processName: String, uid: Int): Boolean = + uid != Process.SYSTEM_UID || processName != "system" +} diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/PreferenceStore.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/PreferenceStore.kt index bf1f4905f..e26b653a1 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/PreferenceStore.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/PreferenceStore.kt @@ -3,8 +3,12 @@ package org.matrix.vector.daemon.data import android.content.ContentValues import android.database.sqlite.SQLiteDatabase import org.apache.commons.lang3.SerializationUtilsX +import org.matrix.vector.daemon.system.* private const val TAG = "VectorPreferenceStore" +private const val INVALIDATE_ART_INLINE_HOOKS_KEY_PREFIX = "invalidate_art_inline_hooks:" +const val SYSTEM_UI_VIRTUAL_PACKAGE = "system" +const val SYSTEM_UI_PROCESS = "system:ui" object PreferenceStore { @@ -100,4 +104,56 @@ object PreferenceStore { fun isScopeRequestBlocked(pkg: String): Boolean = (getModulePrefs("lspd", 0, "config")["scope_request_blocked"] as? Set<*>)?.contains(pkg) == true + + fun getInvalidateArtInlineHookPackages(): Set { + return getModulePrefs("lspd", 0, "config") + .asSequence() + .filter { (key, value) -> + key.startsWith(INVALIDATE_ART_INLINE_HOOKS_KEY_PREFIX) && value == true + } + .map { (key, _) -> key.removePrefix(INVALIDATE_ART_INLINE_HOOKS_KEY_PREFIX) } + .filter { it.isNotBlank() } + .toSet() + } + + /** Updates one package without replacing another Manager client's choices. */ + fun setInvalidateArtInlineHooks(packageName: String, enabled: Boolean): Boolean { + val normalized = packageName.trim() + if (normalized.isEmpty()) return false + updateModulePref( + "lspd", + 0, + "config", + INVALIDATE_ART_INLINE_HOOKS_KEY_PREFIX + normalized, + if (enabled) true else null) + return true + } + + /** + * Resolves the configured package list against the actual process topology for this user. + * This deliberately avoids assuming that every Android process name starts with its package name. + */ + fun shouldInvalidateArtInlineHooks(processName: String, uid: Int): Boolean { + val configured = getInvalidateArtInlineHookPackages() + if (configured.isEmpty()) return false + + if (InlineHookProcessPolicy.matchesSystemUiVirtualPackage(configured, processName, uid)) { + return true + } + + val userId = uid / PER_USER_RANGE + return configured.any { packageName -> + if (packageName == SYSTEM_UI_VIRTUAL_PACKAGE) return@any false + val info = + packageManager?.getPackageInfoWithComponents(packageName, MATCH_ALL_FLAGS, userId) + ?: return@any false + val applicationInfo = info.applicationInfo ?: return@any false + InlineHookProcessPolicy.matchesPackage( + expectedUid = applicationInfo.uid, + actualUid = uid, + processName = processName, + applicationProcessName = applicationInfo.processName, + componentProcesses = info.fetchProcesses()) + } + } } diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/FrameworkService.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/FrameworkService.kt index 44ed1c650..165633d7a 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/FrameworkService.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/FrameworkService.kt @@ -15,6 +15,8 @@ import org.matrix.vector.ipc.IProcessChannel import org.matrix.vector.ipc.IFrameworkService import org.matrix.vector.daemon.data.ConfigCache import org.matrix.vector.daemon.data.FileSystem +import org.matrix.vector.daemon.data.InlineHookProcessPolicy +import org.matrix.vector.daemon.data.PreferenceStore import org.matrix.vector.daemon.system.FIRST_APPLICATION_UID import org.matrix.vector.daemon.system.PER_USER_RANGE import org.matrix.vector.daemon.utils.InstallerVerifier @@ -29,6 +31,8 @@ const val DEX_TRANSACTION_CODE = ('_'.code shl 24) or ('D'.code shl 16) or ('E'.code shl 8) or 'X'.code const val OBFUSCATION_MAP_TRANSACTION_CODE = ('_'.code shl 24) or ('O'.code shl 16) or ('B'.code shl 8) or 'F'.code +const val INVALIDATE_ART_INLINE_HOOKS_TRANSACTION_CODE = + ('_'.code shl 24) or ('I'.code shl 16) or ('N'.code shl 8) or 'L'.code /** * What an injected process asks the framework for — this project's `IFrameworkService`. @@ -241,6 +245,15 @@ object FrameworkService : IFrameworkService.Stub() { } return true } + INVALIDATE_ART_INLINE_HOOKS_TRANSACTION_CODE -> { + val info = ensureRegistered() + val invalidate = + InlineHookProcessPolicy.mayInvalidate(info.processName, info.key.uid) && + PreferenceStore.shouldInvalidateArtInlineHooks(info.processName, info.key.uid) + reply?.writeNoException() + reply?.writeInt(if (invalidate) 1 else 0) + return true + } } return super.onTransact(code, data, reply, flags) } 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 96251dffc..4f2aee083 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 @@ -284,6 +284,12 @@ object ManagerService : IManagerService.Stub() { if (isVerboseLogEnabled()) LogcatMonitor.startVerbose() else LogcatMonitor.stopVerbose() } + override fun getInvalidateArtInlineHookPackages(): MutableList = + PreferenceStore.getInvalidateArtInlineHookPackages().sorted().toMutableList() + + override fun setInvalidateArtInlineHooks(packageName: String, enabled: Boolean): Boolean = + PreferenceStore.setInvalidateArtInlineHooks(packageName, enabled) + override fun getLogParts(verbose: Boolean): List = FileSystem.listLogParts(verbose) override fun getLogPart(verbose: Boolean, name: String): ParcelFileDescriptor? = diff --git a/manager/src/debug/kotlin/org/matrix/vector/manager/demo/FakeManagerService.kt b/manager/src/debug/kotlin/org/matrix/vector/manager/demo/FakeManagerService.kt index a0e7964f0..503568623 100644 --- a/manager/src/debug/kotlin/org/matrix/vector/manager/demo/FakeManagerService.kt +++ b/manager/src/debug/kotlin/org/matrix/vector/manager/demo/FakeManagerService.kt @@ -224,6 +224,12 @@ class FakeManagerService( real?.setVerboseLogEnabled(enabled) } + override fun getInvalidateArtInlineHookPackages(): MutableList = + real?.invalidateArtInlineHookPackages.orEmpty().sorted().toMutableList() + + override fun setInvalidateArtInlineHooks(packageName: String, enabled: Boolean): Boolean = + real?.setInvalidateArtInlineHooks(packageName, enabled) ?: false + override fun getLiveLogPart(verbose: Boolean): ParcelFileDescriptor? = real?.getLiveLogPart(verbose) diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ipc/DaemonClient.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ipc/DaemonClient.kt index b4c0705f8..4637b75f7 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ipc/DaemonClient.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ipc/DaemonClient.kt @@ -218,6 +218,25 @@ class DaemonClient(private val serviceState: StateFlow) { suspend fun setVerboseLogEnabled(enabled: Boolean): Result = runIpc { it.setVerboseLogEnabled(enabled) } + /** + * Every package opted into ART inline-hook invalidation, sorted. + * + * Empty against a daemon too old to answer the call, in which case the manager shows none. + */ + suspend fun getInvalidateArtInlineHookPackages(): Result> = runIpc { + it.invalidateArtInlineHookPackages.orEmpty() + } + + /** + * Sets whether a package invalidates Vector's native ART inline hooks after injection. + * + * [Result] carries the daemon's own answer: it stores the choice and reports whether the write + * landed, so a blank package name or a refused write reaches the caller rather than reading as a + * silent success. + */ + suspend fun setInvalidateArtInlineHooks(packageName: String, enabled: Boolean): Result = + runIpc { it.setInvalidateArtInlineHooks(packageName, enabled) } + /** * The rotated parts the daemon still holds for one of the two logs, oldest first. * diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/VectorApp.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/VectorApp.kt index 7aea69b23..60858abef 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/VectorApp.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/VectorApp.kt @@ -38,12 +38,14 @@ import org.matrix.vector.manager.ui.navigation.StoreDetail import org.matrix.vector.manager.ui.navigation.CrashTrace import org.matrix.vector.manager.ui.navigation.LogTrace import org.matrix.vector.manager.ui.navigation.SystemStatus +import org.matrix.vector.manager.ui.navigation.InvalidateArtInlineHooks import org.matrix.vector.manager.ui.navigation.Web import org.matrix.vector.manager.ui.navigation.TopLevelRoute import org.matrix.vector.manager.ui.navigation.rememberNavigator import org.matrix.vector.manager.ui.screens.home.HomeScreen import org.matrix.vector.manager.ui.screens.home.CrashTraceScreen import org.matrix.vector.manager.ui.screens.home.SystemStatusScreen +import org.matrix.vector.manager.ui.screens.home.InvalidateArtInlineHooksScreen import org.matrix.vector.ui.logs.LogTraceScreen import org.matrix.vector.manager.data.repository.VectorLogSource import org.matrix.vector.manager.ui.theme.LocalizedOverlay @@ -250,8 +252,12 @@ private fun EntryProviderScope.registerRoutes(navigator: Navigator) { SystemStatusScreen( onNavigateBack = { navigator.back() }, onOpenCrash = { navigator.go(CrashTrace) }, + onOpenArtInlineHooks = { navigator.go(InvalidateArtInlineHooks) }, ) } + entry { + InvalidateArtInlineHooksScreen(onNavigateBack = { navigator.back() }) + } entry { CrashTraceScreen(onNavigateBack = { navigator.back() }) } entry { route -> LogTraceScreen(text = route.text, onNavigateBack = { navigator.back() }) diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/navigation/Route.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/navigation/Route.kt index 4f1c81fc2..48b7475a2 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/navigation/Route.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/navigation/Route.kt @@ -50,6 +50,13 @@ sealed interface TopLevelRoute : Route { @Serializable data object SystemStatus : Route +/** + * The per-app ART inline hook compatibility picker. + * + * Read from the system status screen, next to the other framework behaviour toggles. + */ +@Serializable data object InvalidateArtInlineHooks : Route + /** * The newest recorded crash, frame by frame. * diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/InvalidateArtInlineHooksScreen.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/InvalidateArtInlineHooksScreen.kt new file mode 100644 index 000000000..8bad168c5 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/InvalidateArtInlineHooksScreen.kt @@ -0,0 +1,136 @@ +package org.matrix.vector.manager.ui.screens.home + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Android +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import kotlinx.coroutines.launch +import org.matrix.vector.manager.R +import org.matrix.vector.ui.PackageRow +import org.matrix.vector.ui.SearchField +import org.matrix.vector.manager.ui.components.SnackbarTone +import org.matrix.vector.manager.ui.components.VectorSnackbarHost +import org.matrix.vector.manager.ui.components.show + +/** + * The per-app picker behind the "ART inline hook compatibility mode" row. + * + * Every installed app with a switch, plus the Android system UI at the top. Each switch records the + * package in the daemon's preference store; nothing takes effect until that app's process next + * starts, which is said at the top of the list rather than left to be discovered. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun InvalidateArtInlineHooksScreen( + onNavigateBack: () -> Unit, + viewModel: InlineHookViewModel = viewModel(factory = InlineHookLocator.factory()), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val query by viewModel.searchQuery.collectAsStateWithLifecycle() + val message by viewModel.message.collectAsStateWithLifecycle() + val snackbars = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + val saveFailed = stringResource(R.string.invalidate_art_inline_hooks_save_failed) + + LaunchedEffect(message) { + if (message != null) { + scope.launch { snackbars.show(saveFailed, SnackbarTone.Failure) } + viewModel.consumeMessage() + } + } + + Scaffold( + snackbarHost = { VectorSnackbarHost(snackbars) }, + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.invalidate_art_inline_hooks)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(R.string.back), + ) + } + }, + ) + }, + ) { padding -> + LazyColumn( + modifier = Modifier.padding(padding), + contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 8.dp, bottom = 24.dp), + ) { + item { + Text( + stringResource(R.string.invalidate_art_inline_hooks_summary), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(12.dp)) + } + item { + SearchField( + query = query, + onQueryChange = { viewModel.searchQuery.value = it }, + placeholder = stringResource(R.string.invalidate_art_inline_hooks_search_hint), + ) + Spacer(Modifier.height(4.dp)) + } + if (state.loading) { + item { CircularProgressIndicator() } + } else { + items(state.rows, key = { it.packageName }) { row -> + PackageRow( + icon = { + Icon( + imageVector = rowIcon(row.isSystemUi), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + label = + if (row.isSystemUi) { + stringResource(R.string.invalidate_art_inline_hooks_system_ui) + } else { + row.label + }, + packageName = row.appName, + trailing = { + Switch( + checked = row.enabled, + onCheckedChange = { enabled -> viewModel.setEnabled(row, enabled) }, + ) + }, + ) + } + } + } + } +} + +private fun rowIcon(isSystemUi: Boolean): ImageVector = Icons.Rounded.Android diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/InvalidateArtInlineHooksViewModel.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/InvalidateArtInlineHooksViewModel.kt new file mode 100644 index 000000000..fc1e78d8e --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/InvalidateArtInlineHooksViewModel.kt @@ -0,0 +1,171 @@ +package org.matrix.vector.manager.ui.screens.home + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.matrix.vector.manager.data.model.AppInfo +import org.matrix.vector.manager.data.repository.AppRepository +import org.matrix.vector.manager.di.ServiceLocator +import org.matrix.vector.manager.ipc.DaemonClient +import org.matrix.vector.manager.logE +import org.matrix.vector.manager.logW + +/** The key the daemon stores the system UI's opt-in under; see PreferenceStore. */ +const val SYSTEM_UI_VIRTUAL_PACKAGE = "system" + +/** The synthetic display label for the Android system UI process. */ +const val SYSTEM_UI_LABEL = "system" + +/** + * A package the reader can opt into ART inline-hook invalidation, shown as a switch. + * + * Every installed app plus one synthetic row for the system UI, whose process is {@code system:ui} + * and which the daemon resolves from the reserved package name {@code system}. + */ +data class InlineHookRow( + val packageName: String, + val label: String, + val appName: String, + val enabled: Boolean, + val isSystemUi: Boolean = false, +) + +data class InlineHookUiState( + val rows: List = emptyList(), + val loading: Boolean = true, +) + +/** + * Picks which packages invalidate Vector's native ART inline hooks after injection. + * + * This is the manager's view of {@code PreferenceStore.invalidate_art_inline_hooks:*} on the + * daemon. Nothing here knows or cares about the libart.so mechanics — the daemon resolves each + * package name against real process topology when an app next starts, and this screen only + * records the reader's choices. + */ +class InlineHookViewModel( + private val daemonClient: DaemonClient, + private val appRepository: AppRepository, +) : ViewModel() { + + val searchQuery = MutableStateFlow("") + + private val configured = MutableStateFlow>(emptySet()) + private val apps = MutableStateFlow>(emptyList()) + private val loading = MutableStateFlow(true) + + private val _message = MutableStateFlow(null) + val message: StateFlow = _message.asStateFlow() + + val uiState: StateFlow = + combine(apps, configured, searchQuery, loading) { appList, set, query, isLoading -> + val rows = buildList { + val systemUi = InlineHookRow( + packageName = SYSTEM_UI_VIRTUAL_PACKAGE, + label = SYSTEM_UI_LABEL, + appName = SYSTEM_UI_LABEL, + enabled = SYSTEM_UI_VIRTUAL_PACKAGE in set, + isSystemUi = true, + ) + // The system UI leads, then everything else alphabetically. + if (query.isBlank() || SYSTEM_UI_LABEL.contains(query, ignoreCase = true)) { + add(systemUi) + } + appList + .asSequence() + .map { app -> + InlineHookRow( + packageName = app.packageName, + label = app.appName, + appName = app.packageName, + enabled = app.packageName in set, + ) + } + .filter { row -> + query.isBlank() || + row.label.contains(query, ignoreCase = true) || + row.appName.contains(query, ignoreCase = true) + } + .sortedBy { it.label.lowercase() } + .forEach { add(it) } + } + InlineHookUiState(rows = rows, loading = isLoading) + } + .stateIn(viewModelScope, kotlinx.coroutines.flow.SharingStarted.WhileSubscribed(5_000), + InlineHookUiState()) + + init { + viewModelScope.launch { + withContext(Dispatchers.IO) { + apps.value = appRepository.getInstalledApps() + } + loadConfigured() + loading.value = false + } + } + + private suspend fun loadConfigured() { + daemonClient + .getInvalidateArtInlineHookPackages() + .onSuccess { set -> configured.value = set.toSet() } + .onFailure { e -> logW("inline-hooks: reading the configured packages failed", e) } + } + + fun setEnabled(row: InlineHookRow, enabled: Boolean) { + viewModelScope.launch { + daemonClient + .setInvalidateArtInlineHooks(row.packageName, enabled) + .onSuccess { stored -> + // The daemon's answer, not merely the fact that it answered: a blank name is + // refused, and moving the switch on a refusal would show a choice that was + // never saved. + if (stored) { + configured.value = + if (enabled) configured.value + row.packageName + else configured.value - row.packageName + } else { + logE("inline-hooks: daemon refused $enabled for ${row.packageName}") + _message.value = InlineHookMessage.SaveFailed + } + } + .onFailure { e -> + logE("inline-hooks: setting $enabled for ${row.packageName} failed", e) + _message.value = InlineHookMessage.SaveFailed + } + } + } + + fun consumeMessage() { + _message.value = null + } +} + +sealed interface InlineHookMessage { + data object SaveFailed : InlineHookMessage +} + +class InlineHookViewModelFactory( + private val daemonClient: DaemonClient, + private val appRepository: AppRepository, +) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = + InlineHookViewModel(daemonClient, appRepository) as T +} + +/** Accessible from the screen to build the [InlineHookViewModelFactory] the same way [ScopeScreen] does. */ +object InlineHookLocator { + fun factory(): InlineHookViewModelFactory = + InlineHookViewModelFactory( + daemonClient = ServiceLocator.daemon, + appRepository = ServiceLocator.apps, + ) +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/SystemStatusScreen.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/SystemStatusScreen.kt index 293c7692d..ac1934ce9 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/SystemStatusScreen.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/SystemStatusScreen.kt @@ -90,6 +90,7 @@ import org.matrix.vector.manager.ui.theme.VectorMono fun SystemStatusScreen( onNavigateBack: () -> Unit, onOpenCrash: () -> Unit, + onOpenArtInlineHooks: () -> Unit, viewModel: HomeViewModel = viewModel(factory = HomeViewModel.Factory), ) { val status by viewModel.status.collectAsStateWithLifecycle() @@ -227,6 +228,17 @@ fun SystemStatusScreen( onCheckedChange = viewModel::setForcedLauncherIcons, ) } + // The per-app picker is a screen of its own rather than a switch, because there is no + // single on/off to model: it is a choice per package. + item { + FrameworkToggle( + title = stringResource(R.string.invalidate_art_inline_hooks), + subtitle = stringResource(R.string.invalidate_art_inline_hooks_summary), + checked = false, + enabled = daemonAlive, + onCheckedChange = { if (it) onOpenArtInlineHooks() }, + ) + } // How to get back in. Only parasitically: installed, the manager has a launcher icon // like any other app and none of this means anything. diff --git a/manager/src/main/res/values/strings.xml b/manager/src/main/res/values/strings.xml index cc98a39d3..32e868b3a 100644 --- a/manager/src/main/res/values/strings.xml +++ b/manager/src/main/res/values/strings.xml @@ -92,6 +92,12 @@ A persistent notification showing that Vector is running. Stop apps hiding their launcher icons Since Android 10, an app that hides its own launcher icon gets one back that opens its app info page. Turn this off to let it stay hidden — your launcher may not catch up until it restarts. Apps that never had a launcher icon, including most modules, are unaffected. + + ART inline hook compatibility mode + Disable Vector\'s ART inline hooks in selected apps to improve compatibility. Some modules may stop working, and apps may malfunction or crash. Changes apply the next time the app starts. + Search apps + Android system UI + Failed to save ART inline hook setting Recent activity diff --git a/native/include/core/art_inline_hook_invalidation.h b/native/include/core/art_inline_hook_invalidation.h new file mode 100644 index 000000000..a95561885 --- /dev/null +++ b/native/include/core/art_inline_hook_invalidation.h @@ -0,0 +1,33 @@ +#pragma once + +namespace vector::native { + +/** + * Configure per-process ART inline-hook invalidation state before LSPlant initialization. + * + * This is reset for every specialized process. system_server and normal apps that are not on the + * compatibility list pass false, making the post-bootstrap invalidation call a no-op. + * + * Returns whether invalidation is armed. Enabling can fail when the pre-Vector libart executable + * state cannot be captured safely; in that case invalidation remains disabled so existing native + * hooks are never overwritten without a recoverable baseline. + */ +bool ConfigureArtInlineHookInvalidation(bool enabled); + +/** Record/forget native libart.so targets installed through LSPlant's InitInfo hook handler. */ +void RecordArtInlineHookInvalidationTarget(void *target); +void ForgetArtInlineHookInvalidationTarget(void *target); + +/** + * Run the one-shot compatibility invalidation after framework bootstrap and before app loading. + * + * For opted-in apps, replace executable libart.so segments containing recorded Vector/LSPlant + * targets with clean private mappings, then restore executable pages that were already modified + * before Vector installed its hooks. This intentionally does not perform normal Dobby hook teardown, + * so LSPlant/Dobby trampoline and interceptor metadata remain intact while Vector's patched libart + * entry code is removed. + * Disabled/already-invalidated states are successful no-ops. + */ +bool InvalidateArtInlineHooksIfEnabled(); + +} // namespace vector::native diff --git a/native/src/core/art_inline_hook_cleanup.cpp b/native/src/core/art_inline_hook_cleanup.cpp new file mode 100644 index 000000000..a080b6fe4 --- /dev/null +++ b/native/src/core/art_inline_hook_cleanup.cpp @@ -0,0 +1,504 @@ +#include "core/art_inline_hook_invalidation.h" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "common/logging.h" + +namespace vector::native { +namespace { + +std::mutex g_art_invalidation_mutex; +std::vector g_art_inline_hook_targets; +bool g_art_invalidation_enabled = false; +bool g_art_invalidation_completed = false; + +struct LibArtRestoreResult { + bool found = false; + bool success = false; + size_t executable_segments = 0; + size_t modified_pages = 0; + size_t restored_bytes = 0; +}; + +struct PreservedExecutablePage { + uintptr_t address = 0; + std::vector bytes; +}; + +struct LibArtSnapshotResult { + bool found = false; + bool success = false; + std::vector modified_pages; +}; + +std::vector g_preserved_art_pages; + +bool IsLibArtPath(const char *path) { + if (!path || *path == '\0') return false; + const char *name = std::strrchr(path, '/'); + name = name ? name + 1 : path; + return std::strcmp(name, "libart.so") == 0; +} + +int ProtectionFromFlags(ElfW(Word) flags) { + int protection = 0; + if ((flags & PF_R) != 0) protection |= PROT_READ; + if ((flags & PF_W) != 0) protection |= PROT_WRITE; + if ((flags & PF_X) != 0) protection |= PROT_EXEC; + return protection; +} + +bool GetPageLayout(size_t &page_size, uintptr_t &page_mask) { + const long value = sysconf(_SC_PAGESIZE); + if (value <= 0 || (value & (value - 1)) != 0) { + LOGE("Failed to determine a valid page size while processing libart.so"); + return false; + } + page_size = static_cast(value); + page_mask = static_cast(page_size - 1); + return true; +} + +bool SegmentContainsTrackedTarget(uintptr_t segment_start, uintptr_t segment_end) { + return std::any_of(g_art_inline_hook_targets.begin(), g_art_inline_hook_targets.end(), + [segment_start, segment_end](const void *target) { + const auto address = reinterpret_cast(target); + return address >= segment_start && address < segment_end; + }); +} + +bool CaptureExecutablePages(const dl_phdr_info *info, LibArtSnapshotResult &result) { + const char *path = info->dlpi_name; + int fd = open(path, O_RDONLY | O_CLOEXEC); + if (fd < 0) { + PLOGE("Failed to open libart backing file '{}' while capturing existing hooks", path); + return false; + } + + struct stat file_stat {}; + if (fstat(fd, &file_stat) != 0 || file_stat.st_size <= 0) { + PLOGE("Failed to stat libart backing file '{}' while capturing existing hooks", path); + close(fd); + return false; + } + + const size_t file_size = static_cast(file_stat.st_size); + void *file_map = mmap(nullptr, file_size, PROT_READ, MAP_PRIVATE, fd, 0); + if (file_map == MAP_FAILED) { + PLOGE("Failed to map libart backing file '{}' while capturing existing hooks", path); + close(fd); + return false; + } + + size_t page_size = 0; + uintptr_t page_mask = 0; + if (!GetPageLayout(page_size, page_mask)) { + munmap(file_map, file_size); + close(fd); + return false; + } + + bool success = true; + const auto *clean_file = static_cast(file_map); + for (ElfW(Half) i = 0; i < info->dlpi_phnum; ++i) { + const ElfW(Phdr) &phdr = info->dlpi_phdr[i]; + if (phdr.p_type != PT_LOAD || (phdr.p_flags & PF_X) == 0 || phdr.p_filesz == 0) continue; + + const size_t file_offset = static_cast(phdr.p_offset); + const size_t segment_size = static_cast(phdr.p_filesz); + const uintptr_t image_base = static_cast(info->dlpi_addr); + const uintptr_t virtual_address = static_cast(phdr.p_vaddr); + if (virtual_address > UINTPTR_MAX - image_base) { + LOGE("Executable libart segment {} snapshot address overflows", i); + success = false; + continue; + } + const uintptr_t segment_start = image_base + virtual_address; + if (segment_size > UINTPTR_MAX - segment_start) { + LOGE("Executable libart segment {} snapshot range overflows", i); + success = false; + continue; + } + const size_t first_page_prefix = static_cast(segment_start & page_mask); + if (file_offset < first_page_prefix || segment_size > SIZE_MAX - first_page_prefix) { + LOGE("Executable libart segment {} has invalid aligned snapshot bounds", i); + success = false; + continue; + } + + const size_t mapping_span = first_page_prefix + segment_size; + if (mapping_span > SIZE_MAX - page_mask) { + LOGE("Executable libart segment {} snapshot size overflows", i); + success = false; + continue; + } + const size_t mapping_size = (mapping_span + page_mask) & ~page_mask; + const size_t first_page_file_offset = file_offset - first_page_prefix; + if (first_page_file_offset > file_size || mapping_size > file_size - first_page_file_offset) { + LOGE("Executable libart segment {} aligned snapshot exceeds backing file bounds", i); + success = false; + continue; + } + + const uintptr_t mapping_start = segment_start & ~page_mask; + for (size_t offset = 0; offset < mapping_size; offset += page_size) { + auto *live = reinterpret_cast(mapping_start + offset); + const auto *clean = clean_file + first_page_file_offset + offset; + if (std::memcmp(live, clean, page_size) == 0) continue; + + PreservedExecutablePage page; + page.address = mapping_start + offset; + page.bytes.assign(live, live + page_size); + result.modified_pages.emplace_back(std::move(page)); + } + } + + munmap(file_map, file_size); + close(fd); + return success; +} + +int CaptureLibArtCallback(dl_phdr_info *info, size_t, void *data) { + if (!IsLibArtPath(info->dlpi_name)) return 0; + + auto &result = *static_cast(data); + result.found = true; + result.success = CaptureExecutablePages(info, result); + return 1; +} + +LibArtSnapshotResult CaptureLibArtExecutableState() { + LibArtSnapshotResult result; + dl_iterate_phdr(CaptureLibArtCallback, &result); + if (!result.found) LOGE("Unable to locate loaded libart.so before installing ART hooks"); + return result; +} + +bool PreparePreservedPagesForRewrite(uintptr_t mapping_start, uintptr_t mapping_end, + int original_protection, size_t page_size) { + for (const auto &page : g_preserved_art_pages) { + if (page.address < mapping_start || page.address >= mapping_end) continue; + if (page.bytes.size() != page_size) { + LOGE("Invalid preserved libart page size at {}", reinterpret_cast(page.address)); + return false; + } + + const int writable_protection = original_protection | PROT_WRITE; + if (mprotect(reinterpret_cast(page.address), page_size, writable_protection) != 0) { + PLOGE("Cannot make preserved libart page at {} writable", + reinterpret_cast(page.address)); + return false; + } + if (mprotect(reinterpret_cast(page.address), page_size, original_protection) != 0) { + PLOGE("Cannot restore protection for preserved libart page at {}", + reinterpret_cast(page.address)); + return false; + } + } + return true; +} + +bool RestorePreservedPages(uintptr_t mapping_start, uintptr_t mapping_end, int original_protection, + size_t page_size) { + bool success = true; + for (const auto &page : g_preserved_art_pages) { + if (page.address < mapping_start || page.address >= mapping_end) continue; + + const int writable_protection = original_protection | PROT_WRITE; + if (mprotect(reinterpret_cast(page.address), page_size, writable_protection) != 0) { + PLOGE("Failed to make preserved libart page at {} writable", + reinterpret_cast(page.address)); + success = false; + continue; + } + std::memcpy(reinterpret_cast(page.address), page.bytes.data(), page_size); + __builtin___clear_cache(reinterpret_cast(page.address), + reinterpret_cast(page.address + page_size)); + if (mprotect(reinterpret_cast(page.address), page_size, original_protection) != 0) { + PLOGE("Failed to restore protection for preserved libart page at {}", + reinterpret_cast(page.address)); + success = false; + } + } + return success; +} + +bool RestoreExecutableSegments(const dl_phdr_info *info, LibArtRestoreResult &result) { + const char *path = info->dlpi_name; + int fd = open(path, O_RDONLY | O_CLOEXEC); + if (fd < 0) { + PLOGE("Failed to open libart backing file '{}'", path); + return false; + } + + struct stat file_stat {}; + if (fstat(fd, &file_stat) != 0 || file_stat.st_size <= 0) { + PLOGE("Failed to stat libart backing file '{}'", path); + close(fd); + return false; + } + + const size_t file_size = static_cast(file_stat.st_size); + void *file_map = mmap(nullptr, file_size, PROT_READ, MAP_PRIVATE, fd, 0); + if (file_map == MAP_FAILED) { + PLOGE("Failed to map libart backing file '{}'", path); + close(fd); + return false; + } + + size_t page_size = 0; + uintptr_t page_mask = 0; + if (!GetPageLayout(page_size, page_mask)) { + munmap(file_map, file_size); + close(fd); + return false; + } + + bool success = true; + const auto *clean_file = static_cast(file_map); + + for (ElfW(Half) i = 0; i < info->dlpi_phnum; ++i) { + const ElfW(Phdr) &phdr = info->dlpi_phdr[i]; + if (phdr.p_type != PT_LOAD || (phdr.p_flags & PF_X) == 0 || phdr.p_filesz == 0) { + continue; + } + + const size_t file_offset = static_cast(phdr.p_offset); + const size_t segment_size = static_cast(phdr.p_filesz); + if (file_offset > file_size || segment_size > file_size - file_offset) { + LOGE("Executable libart segment {} exceeds backing file bounds", i); + success = false; + continue; + } + + const uintptr_t segment_start = + static_cast(info->dlpi_addr) + static_cast(phdr.p_vaddr); + if (segment_size > UINTPTR_MAX - segment_start) { + LOGE("Executable libart segment {} address range overflows", i); + success = false; + continue; + } + const uintptr_t segment_end = segment_start + segment_size; + if (!SegmentContainsTrackedTarget(segment_start, segment_end)) continue; + ++result.executable_segments; + const auto *clean_segment = clean_file + file_offset; + const int original_protection = ProtectionFromFlags(phdr.p_flags); + const size_t first_page_prefix = static_cast(segment_start & page_mask); + if (file_offset < first_page_prefix) { + LOGE("Executable libart segment {} has an invalid page-aligned file offset", i); + success = false; + continue; + } + const size_t first_page_file_offset = file_offset - first_page_prefix; + + // Count dirty pages first, then replace the complete executable PT_LOAD in one mmap. Mapping + // individual dirty pages leaves visible VMA boundaries around every former trampoline; + // protection libraries can treat that non-standard libart layout as tampering even when all + // instruction bytes have been restored. A segment-wide file mapping recreates the loader's + // normal contiguous VMA shape. Only pages containing preserved pre-Vector modifications are + // made transiently writable below, and only after writability has been preflighted. + const uintptr_t mapping_start = segment_start & ~page_mask; + uintptr_t page_start = mapping_start; + size_t segment_modified_pages = 0; + size_t segment_restored_bytes = 0; + while (page_start < segment_end) { + const uintptr_t next_page = page_start + page_size; + if (next_page < page_start) { + LOGE("Page range overflow while invalidating libart.so"); + success = false; + break; + } + + const uintptr_t copy_start = std::max(page_start, segment_start); + const uintptr_t copy_end = std::min(next_page, segment_end); + const size_t copy_size = static_cast(copy_end - copy_start); + const size_t segment_offset = static_cast(copy_start - segment_start); + auto *live = reinterpret_cast(copy_start); + const auto *clean = clean_segment + segment_offset; + + if (std::memcmp(live, clean, copy_size) != 0) { + ++segment_modified_pages; + segment_restored_bytes += copy_size; + } + + page_start = next_page; + } + + if (segment_modified_pages == 0) continue; + + const size_t mapping_span = first_page_prefix + segment_size; + if (mapping_span > SIZE_MAX - page_mask) { + LOGE("Executable libart segment {} mapping size overflows", i); + success = false; + continue; + } + const size_t mapping_size = (mapping_span + page_mask) & ~page_mask; + if (mapping_size > UINTPTR_MAX - mapping_start) { + LOGE("Executable libart segment {} aligned address range overflows", i); + success = false; + continue; + } + const uintptr_t mapping_end = mapping_start + mapping_size; + if (first_page_file_offset > file_size || + mapping_size > file_size - first_page_file_offset) { + LOGE("Executable libart segment {} aligned mapping exceeds backing file bounds", i); + success = false; + continue; + } + + // Verify that pre-Vector modifications in this segment can be rewritten before replacing + // its mapping. If this fails, leave the original mapping and every external hook intact. + if (!PreparePreservedPagesForRewrite(mapping_start, mapping_end, original_protection, + page_size)) { + success = false; + continue; + } + + void *mapped = mmap(reinterpret_cast(mapping_start), mapping_size, + original_protection, MAP_PRIVATE | MAP_FIXED, fd, + static_cast(first_page_file_offset)); + if (mapped == MAP_FAILED) { + PLOGE("Failed to invalidate executable libart segment {} at {} from backing offset {}", + i, reinterpret_cast(mapping_start), first_page_file_offset); + success = false; + continue; + } + if (!RestorePreservedPages(mapping_start, mapping_end, original_protection, page_size)) { + success = false; + continue; + } + __builtin___clear_cache(reinterpret_cast(mapping_start), + reinterpret_cast(mapping_end)); + result.modified_pages += segment_modified_pages; + result.restored_bytes += segment_restored_bytes; + } + + munmap(file_map, file_size); + close(fd); + return success && result.executable_segments > 0; +} + +int RestoreLibArtCallback(dl_phdr_info *info, size_t, void *data) { + if (!IsLibArtPath(info->dlpi_name)) return 0; + + auto &result = *static_cast(data); + result.found = true; + result.success = RestoreExecutableSegments(info, result); + return 1; // libart.so is unique in an app process; stop after handling it. +} + +LibArtRestoreResult RestoreLibArtExecutableBytes() { + LibArtRestoreResult result; + dl_iterate_phdr(RestoreLibArtCallback, &result); + if (!result.found) { + LOGE("Unable to locate loaded libart.so for executable-byte invalidation"); + } + return result; +} + +} // namespace + +bool ConfigureArtInlineHookInvalidation(bool enabled) { + std::lock_guard lock(g_art_invalidation_mutex); + g_art_inline_hook_targets.clear(); + g_preserved_art_pages.clear(); + g_art_invalidation_enabled = false; + g_art_invalidation_completed = false; + + if (!enabled) return false; + + auto snapshot = CaptureLibArtExecutableState(); + if (!snapshot.success) { + LOGW("ART inline-hook invalidation was not armed because the pre-Vector libart.so state " + "could not be captured safely."); + return false; + } + + g_preserved_art_pages = std::move(snapshot.modified_pages); + g_art_invalidation_enabled = true; + LOGI("Preserved {} pre-existing modified libart.so executable page(s) before installing " + "Vector hooks.", + g_preserved_art_pages.size()); + return true; +} + +void RecordArtInlineHookInvalidationTarget(void *target) { + if (!target) return; + + std::lock_guard lock(g_art_invalidation_mutex); + if (!g_art_invalidation_enabled || g_art_invalidation_completed) return; + + if (std::find(g_art_inline_hook_targets.begin(), g_art_inline_hook_targets.end(), target) == + g_art_inline_hook_targets.end()) { + g_art_inline_hook_targets.push_back(target); + } +} + +void ForgetArtInlineHookInvalidationTarget(void *target) { + if (!target) return; + + std::lock_guard lock(g_art_invalidation_mutex); + g_art_inline_hook_targets.erase( + std::remove(g_art_inline_hook_targets.begin(), g_art_inline_hook_targets.end(), target), + g_art_inline_hook_targets.end()); +} + +bool InvalidateArtInlineHooksIfEnabled() { + std::lock_guard lock(g_art_invalidation_mutex); + if (!g_art_invalidation_enabled || g_art_invalidation_completed) return true; + + const size_t tracked_targets = g_art_inline_hook_targets.size(); + LOGI("Running libart.so executable-byte invalidation after framework bootstrap " + "({} tracked LSPlant target(s)).", + tracked_targets); + + if (tracked_targets == 0) { + g_preserved_art_pages.clear(); + g_art_invalidation_completed = true; + g_art_invalidation_enabled = false; + LOGI("No Vector/LSPlant ART inline-hook targets were installed; invalidation is unnecessary."); + return true; + } + + // Deliberately do not call DobbyDestroy/UnhookInline here. LSPosed-style invalidation is a + // compatibility operation, not normal hook teardown: restore libart.so's file-backed executable + // image, reapply modifications captured before Vector initialized, and leave LSPlant/Dobby + // trampoline and interceptor metadata intact. Apps opting into this mode accept that Vector's + // ART maintenance hooks no longer execute afterwards. + const LibArtRestoreResult result = RestoreLibArtExecutableBytes(); + if (!result.success) { + g_art_inline_hook_targets.clear(); + g_preserved_art_pages.clear(); + g_art_invalidation_enabled = false; + LOGW("libart.so executable-byte invalidation failed; ART inline-hook invalidation mode " + "was not fully applied in this process."); + return false; + } + + g_art_inline_hook_targets.clear(); + g_preserved_art_pages.clear(); + g_art_invalidation_completed = true; + g_art_invalidation_enabled = false; + + if (result.modified_pages == 0) { + LOGI("libart.so executable segments already match the backing file ({} segment(s) checked).", + result.executable_segments); + } else { + LOGI("Invalidated libart.so executable pages from backing file: {} modified page(s), {} " + "file-backed byte(s) restored across {} executable segment(s).", + result.modified_pages, result.restored_bytes, result.executable_segments); + } + return true; +} + +} // namespace vector::native diff --git a/services/manager-service/src/main/aidl/org/matrix/vector/ipc/IManagerService.aidl b/services/manager-service/src/main/aidl/org/matrix/vector/ipc/IManagerService.aidl index 19319ba12..9c8aa1646 100644 --- a/services/manager-service/src/main/aidl/org/matrix/vector/ipc/IManagerService.aidl +++ b/services/manager-service/src/main/aidl/org/matrix/vector/ipc/IManagerService.aidl @@ -73,7 +73,7 @@ interface IManagerService { * transaction ids follow declaration order, this number is the only thing standing between a * mismatched pair and a call that lands on the wrong method.

*/ - const int PROTOCOL_VERSION = 1; + const int PROTOCOL_VERSION = 2; /** * Which generation of this interface the daemon implements, never below 1. @@ -406,6 +406,31 @@ interface IManagerService { */ void setVerboseLogEnabled(boolean enabled); + // ---- ART inline hook compatibility mode ----------------------------------------------------- + + /** + * Every package opted into ART inline-hook invalidation, sorted. + * + *

Also names {@code system} when the system UI (whose process is {@code system:ui}) is on + * the list, and names nothing else synthetic: {@link #getInvalidateArtInlineHooks} returns the + * configured set verbatim. The empty set is the ordinary answer on a device where nobody has + * touched the setting.

+ */ + List getInvalidateArtInlineHookPackages(); + + /** + * Sets whether a package invalidates Vector's native ART inline hooks after injection. + * + *

Opting in makes a process restore libart.so's file-backed executable image and its own + * pre-injection modifications after the framework bootstrap, leaving LSPlant/Dobby metadata + * intact. It is a compatibility operation for apps whose protection rejects the temporary + * patches; the framework's own maintenance hooks no longer run afterwards.

+ * + * @return whether the daemon stored it, which is not whether the call arrived. False means the + * package name was blank - nothing else is refused + */ + boolean setInvalidateArtInlineHooks(String packageName, boolean enabled); + // ---- logs ------------------------------------------------------------------------------------- /** diff --git a/zygisk/src/main/cpp/include/ipc_bridge.h b/zygisk/src/main/cpp/include/ipc_bridge.h index 748840671..6eaaee17e 100644 --- a/zygisk/src/main/cpp/include/ipc_bridge.h +++ b/zygisk/src/main/cpp/include/ipc_bridge.h @@ -81,6 +81,12 @@ class IPCBridge { */ std::map FetchObfuscationMap(JNIEnv *env, jobject binder); + /** + * @brief Queries whether this registered application process should invalidate Vector's + * native ART inline hooks after framework initialization. + */ + bool ShouldInvalidateArtInlineHooks(JNIEnv *env, jobject binder); + /** * @brief Sets up the JNI hook to intercept Binder transactions. * diff --git a/zygisk/src/main/cpp/ipc_bridge.cpp b/zygisk/src/main/cpp/ipc_bridge.cpp index bc19adfd8..0135d6497 100644 --- a/zygisk/src/main/cpp/ipc_bridge.cpp +++ b/zygisk/src/main/cpp/ipc_bridge.cpp @@ -89,6 +89,8 @@ constexpr auto kBridgeServiceName = "activity"sv; constexpr jint kBridgeTransactionCode = ('_' << 24) | ('V' << 16) | ('E' << 8) | 'C'; constexpr jint kDexTransactionCode = ('_' << 24) | ('D' << 16) | ('E' << 8) | 'X'; constexpr jint kObfuscationMapTransactionCode = ('_' << 24) | ('O' << 16) | ('B' << 8) | 'F'; +constexpr jint kInvalidateArtInlineHooksTransactionCode = + ('_' << 24) | ('I' << 16) | ('N' << 8) | 'L'; // Action codes sent within a kBridgeTransactionCode transaction. constexpr jint kActionGetBinder = 2; @@ -453,6 +455,30 @@ std::map IPCBridge::FetchObfuscationMap(JNIEnv *env, j return result_map; } +bool IPCBridge::ShouldInvalidateArtInlineHooks(JNIEnv *env, jobject binder) { + if (!initialized_ || !binder) { + return false; + } + + ParcelWrapper parcels(env, this); + bool success = lsplant::JNI_CallBooleanMethod( + env, binder, transact_method_, kInvalidateArtInlineHooksTransactionCode, parcels.data.get(), + parcels.reply.get(), 0); + if (!success) { + LOGW("ART inline hook invalidation policy query failed."); + return false; + } + + lsplant::JNI_CallVoidMethod(env, parcels.reply.get(), read_exception_method_); + if (env->ExceptionCheck()) { + LOGW("Remote exception while querying ART inline hook invalidation policy."); + env->ExceptionClear(); + return false; + } + + return lsplant::JNI_CallIntMethod(env, parcels.reply.get(), read_int_method_) != 0; +} + jboolean IPCBridge::ExecTransact_Replace(jboolean *res, JNIEnv *env, jobject obj, va_list args) { va_list copy; va_copy(copy, args); diff --git a/zygisk/src/main/cpp/module.cpp b/zygisk/src/main/cpp/module.cpp index 84ff88312..25d45e1fc 100644 --- a/zygisk/src/main/cpp/module.cpp +++ b/zygisk/src/main/cpp/module.cpp @@ -1,5 +1,6 @@ #include #include +#include #include #include #include @@ -111,17 +112,39 @@ class VectorModule : public zygisk::ModuleBase, public vector::native::Context { */ void SetAllowUnload(bool unload); + /** + * @brief Creates LSPlant configuration while recording every native ART inline hook that + * LSPlant successfully installs in this process. + */ + lsplant::InitInfo MakeArtHookInitInfo(); + zygisk::Api *api_ = nullptr; JNIEnv *env_ = nullptr; - // --- ART Hooker Configuration --- - const lsplant::InitInfo init_info_{ + // State managed within the class instance for each forked process. + bool should_inject_ = false; + bool is_manager_app_ = false; +}; + +// ========================================================================================= +// Implementation of VectorModule +// ========================================================================================= + +lsplant::InitInfo VectorModule::MakeArtHookInitInfo() { + return lsplant::InitInfo{ .inline_hooker = - [](auto target, auto replace) { + [](auto target, auto replace) -> void * { void *backup = nullptr; - return HookInline(target, replace, &backup) == 0 ? backup : nullptr; + if (HookInline(target, replace, &backup) != 0) return nullptr; + RecordArtInlineHookInvalidationTarget(target); + return backup; + }, + .inline_unhooker = + [](auto target) { + if (UnhookInline(target) != 0) return false; + ForgetArtInlineHookInvalidationTarget(target); + return true; }, - .inline_unhooker = [](auto target) { return UnhookInline(target) == 0; }, .art_symbol_resolver = [](auto symbol) { return ElfSymbolCache::GetArt()->getSymbAddress(symbol); }, .art_symbol_prefix_resolver = @@ -129,15 +152,7 @@ class VectorModule : public zygisk::ModuleBase, public vector::native::Context { .generated_class_name = "Vector_", .generated_source_name = "Dobby", }; - - // State managed within the class instance for each forked process. - bool should_inject_ = false; - bool is_manager_app_ = false; -}; - -// ========================================================================================= -// Implementation of VectorModule -// ========================================================================================= +} void VectorModule::LoadDex(JNIEnv *env, PreloadedDex &&dex) { LOGV("Loading framework DEX into memory (size: {}).", dex.size()); @@ -347,14 +362,29 @@ void VectorModule::postAppSpecialize(const zygisk::AppSpecializeArgs *args) { auto obfs_map = ipc_bridge.FetchObfuscationMap(env_, binder.get()); ConfigBridge::GetInstance()->obfuscation_map(std::move(obfs_map)); + const bool invalidate_art_inline_hooks_requested = + !is_manager_app_ && ipc_bridge.ShouldInvalidateArtInlineHooks(env_, binder.get()); + const bool invalidate_art_inline_hooks = + ConfigureArtInlineHookInvalidation(invalidate_art_inline_hooks_requested); + if (invalidate_art_inline_hooks) { + LOGI("ART inline hook invalidation mode enabled for '{}'; invalidation will run " + "immediately after framework bootstrap.", + nice_name_str.get()); + } else if (invalidate_art_inline_hooks_requested) { + LOGW("ART inline hook invalidation mode could not be armed for '{}'.", + nice_name_str.get()); + } + { PreloadedDex dex(dex_fd, dex_size); this->LoadDex(env_, std::move(dex)); } close(dex_fd); // The FD is duplicated by mmap, we can close it now. - // Initialize ART hooks via the native library. - this->InitArtHooker(env_, init_info_); + // Initialize ART hooks via the native library. The compatibility path records this handler's + // libart.so targets so their executable pages can be restored after framework bootstrap. + auto art_hook_init_info = MakeArtHookInitInfo(); + this->InitArtHooker(env_, art_hook_init_info); // Initialize JNI hooks via the native library. this->InitHooks(env_); // Find the Java entrypoint. @@ -365,6 +395,14 @@ void VectorModule::postAppSpecialize(const zygisk::AppSpecializeArgs *args) { env_, "forkCommon", "(ZZLjava/lang/String;Ljava/lang/String;Landroid/os/IBinder;)V", JNI_FALSE, JNI_FALSE, args->nice_name, args->app_data_dir, binder.get(), is_manager_app_); + // Run this before LoadedApk creates the application's class loader and before app protection + // libraries can observe or derive state from the temporary LSPlant/Dobby patches. forkCommon + // has already installed Vector's Java lifecycle hooks, so no later package-ready callback is + // required merely to bootstrap the framework. + if (invalidate_art_inline_hooks && !InvalidateArtInlineHooksIfEnabled()) { + LOGW("Early ART inline-hook invalidation failed in '{}'.", nice_name_str.get()); + } + if (entered) { LOGV("Injected Vector framework into '{}'.", nice_name_str.get()); } else { @@ -450,7 +488,11 @@ void VectorModule::postServerSpecialize(const zygisk::ServerSpecializeArgs *args ipc_bridge.HookBridge(env_); - this->InitArtHooker(env_, init_info_); + // system_server intentionally keeps the full LSPlant ART maintenance hooks. This preserves the + // existing soft-restart and late-reinjection recovery path. + (void)ConfigureArtInlineHookInvalidation(false); + auto art_hook_init_info = MakeArtHookInitInfo(); + this->InitArtHooker(env_, art_hook_init_info); this->InitHooks(env_); this->SetupEntryClass(env_);