From dc0b609b77870326a50ad7354dce88f5a87c22ca Mon Sep 17 00:00:00 2001 From: inthewaves Date: Fri, 29 May 2026 16:00:24 -0700 Subject: [PATCH] add GosCompatSecureSpawnTests --- .../GosCompatCheckApp/AndroidManifest.xml | 4 + .../checks/GosCompatCheckActivity.kt | 39 + .../GosCompatSecureSpawnTests/Android.bp | 22 + .../GosCompatSecureSpawnTests/AndroidTest.xml | 16 + .../GosCompatSecureSpawnApp/Android.bp | 76 ++ .../AndroidManifest.profileable.xml | 16 + .../AndroidManifest.xml | 24 + .../jni/secure_spawn_jni.c | 28 + .../securespawn/SecureSpawnActivity.kt | 677 ++++++++++++++++++ .../securespawn/SecureSpawnCheck.java | 109 +++ .../securespawn/SecureSpawnDeviceTest.java | 161 +++++ .../shared/SecureSpawnDumpableCheck.java | 25 + .../shared/SecureSpawnHiddenApiCheck.java | 67 ++ .../SecureSpawnReflectiveDumpCheck.java | 483 +++++++++++++ .../shared/SecureSpawnSmapsCheck.java | 211 ++++++ .../shared/SecureSpawnTestApiCompatCheck.java | 238 ++++++ .../GosCompatSecureSpawnTests/README.md | 24 + .../tests/SecureSpawnDisabledHostTest.java | 51 ++ .../tests/SecureSpawnEnabledHostTest.java | 31 + .../tests/SecureSpawnHostTestBase.java | 427 +++++++++++ tests/GosCompatTests/README.md | 7 +- tests/GosCompatTests/TEST_MAPPING | 4 + .../goscompat/checks/GosCompatContract.java | 8 + 23 files changed, 2745 insertions(+), 3 deletions(-) create mode 100644 tests/GosCompatTests/GosCompatSecureSpawnTests/Android.bp create mode 100644 tests/GosCompatTests/GosCompatSecureSpawnTests/AndroidTest.xml create mode 100644 tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/Android.bp create mode 100644 tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/AndroidManifest.profileable.xml create mode 100644 tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/AndroidManifest.xml create mode 100644 tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/jni/secure_spawn_jni.c create mode 100644 tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/SecureSpawnActivity.kt create mode 100644 tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/SecureSpawnCheck.java create mode 100644 tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/SecureSpawnDeviceTest.java create mode 100644 tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/shared/SecureSpawnDumpableCheck.java create mode 100644 tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/shared/SecureSpawnHiddenApiCheck.java create mode 100644 tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/shared/SecureSpawnReflectiveDumpCheck.java create mode 100644 tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/shared/SecureSpawnSmapsCheck.java create mode 100644 tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/shared/SecureSpawnTestApiCompatCheck.java create mode 100644 tests/GosCompatTests/GosCompatSecureSpawnTests/README.md create mode 100644 tests/GosCompatTests/GosCompatSecureSpawnTests/src/app/grapheneos/goscompat/securespawn/tests/SecureSpawnDisabledHostTest.java create mode 100644 tests/GosCompatTests/GosCompatSecureSpawnTests/src/app/grapheneos/goscompat/securespawn/tests/SecureSpawnEnabledHostTest.java create mode 100644 tests/GosCompatTests/GosCompatSecureSpawnTests/src/app/grapheneos/goscompat/securespawn/tests/SecureSpawnHostTestBase.java diff --git a/tests/GosCompatTests/GosCompatCheckApp/AndroidManifest.xml b/tests/GosCompatTests/GosCompatCheckApp/AndroidManifest.xml index 2e6e818ccc050..b9ae39049e4ca 100644 --- a/tests/GosCompatTests/GosCompatCheckApp/AndroidManifest.xml +++ b/tests/GosCompatTests/GosCompatCheckApp/AndroidManifest.xml @@ -2,6 +2,10 @@ + + + + , @@ -152,6 +169,7 @@ private fun GosCompatCheckScreen( reflectiveMapsScanRunning: Boolean, onRunDirectMapsScan: () -> Unit, onRunReflectiveMapsScan: () -> Unit, + onRunSecureSpawn: () -> Unit, dmaBufReleaseContent: @Composable () -> Unit, ) { Column( @@ -168,6 +186,27 @@ private fun GosCompatCheckScreen( fontWeight = FontWeight.SemiBold, ) + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Text( + text = "Secure app spawning", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + ) + Button(onClick = onRunSecureSpawn) { + Text("Open check") + } + } + } + Card( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(8.dp), diff --git a/tests/GosCompatTests/GosCompatSecureSpawnTests/Android.bp b/tests/GosCompatTests/GosCompatSecureSpawnTests/Android.bp new file mode 100644 index 0000000000000..21dea19e82f8e --- /dev/null +++ b/tests/GosCompatTests/GosCompatSecureSpawnTests/Android.bp @@ -0,0 +1,22 @@ +java_test_host { + name: "GosCompatSecureSpawnTests", + srcs: [ + "src/**/*.java", + ":GosPackageStateFlags", + ], + libs: [ + "tradefed", + "compatibility-tradefed", + "compatibility-host-util", + ], + static_libs: [ + "framework-annotations-lib", + ], + test_suites: [ + "general-tests", + ], + device_common_data: [ + ":GosCompatSecureSpawnApp", + ":GosCompatSecureSpawnProfileableApp", + ], +} diff --git a/tests/GosCompatTests/GosCompatSecureSpawnTests/AndroidTest.xml b/tests/GosCompatTests/GosCompatSecureSpawnTests/AndroidTest.xml new file mode 100644 index 0000000000000..4b8dbbbae2a1b --- /dev/null +++ b/tests/GosCompatTests/GosCompatSecureSpawnTests/AndroidTest.xml @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/Android.bp b/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/Android.bp new file mode 100644 index 0000000000000..3013769a48ab8 --- /dev/null +++ b/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/Android.bp @@ -0,0 +1,76 @@ +cc_library_shared { + name: "libgoscompat_secure_spawn_jni", + srcs: ["jni/secure_spawn_jni.c"], + sdk_version: "current", + header_libs: ["jni_headers"], + cflags: [ + "-Wall", + "-Wextra", + "-Werror", + ], + stl: "none", +} + +goscompat_secure_spawn_java_srcs = [ + "src/**/*.java", +] + +goscompat_secure_spawn_app_srcs = goscompat_secure_spawn_java_srcs + [ + "src/**/*.kt", +] + +goscompat_secure_spawn_test_static_libs = [ + "androidx.test.ext.junit", + "androidx.test.runner", + "truth", +] + +goscompat_secure_spawn_app_static_libs = goscompat_secure_spawn_test_static_libs + [ + "androidx.activity_activity", + "androidx.activity_activity-compose", + "androidx.core_core", + "androidx.compose.foundation_foundation", + "androidx.compose.foundation_foundation-layout", + "androidx.compose.material3_material3", + "androidx.compose.runtime_runtime", + "androidx.compose.ui_ui", + "androidx.compose.ui_ui-graphics", + "androidx.compose.ui_ui-text", + "androidx.compose.ui_ui-unit", +] + +android_test_helper_app { + name: "GosCompatSecureSpawnApp", + srcs: goscompat_secure_spawn_app_srcs, + manifest: "AndroidManifest.xml", + jni_libs: [ + "libgoscompat_secure_spawn_jni", + ], + use_embedded_native_libs: true, + compile_multilib: "both", + libs: [ + "android.test.runner.stubs", + ], + static_libs: goscompat_secure_spawn_app_static_libs, + kotlincflags: [ + "-Xjvm-default=all", + "-P plugin:androidx.compose.compiler.plugins.kotlin:sourceInformation=true", + ], + sdk_version: "current", +} + +android_test_helper_app { + name: "GosCompatSecureSpawnProfileableApp", + srcs: goscompat_secure_spawn_java_srcs, + manifest: "AndroidManifest.profileable.xml", + jni_libs: [ + "libgoscompat_secure_spawn_jni", + ], + use_embedded_native_libs: true, + compile_multilib: "both", + libs: [ + "android.test.runner.stubs", + ], + static_libs: goscompat_secure_spawn_test_static_libs, + sdk_version: "current", +} diff --git a/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/AndroidManifest.profileable.xml b/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/AndroidManifest.profileable.xml new file mode 100644 index 0000000000000..cabfb3a7d16be --- /dev/null +++ b/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/AndroidManifest.profileable.xml @@ -0,0 +1,16 @@ + + + + + + + + + + diff --git a/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/AndroidManifest.xml b/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/AndroidManifest.xml new file mode 100644 index 0000000000000..d9e2fa360011d --- /dev/null +++ b/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/AndroidManifest.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + diff --git a/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/jni/secure_spawn_jni.c b/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/jni/secure_spawn_jni.c new file mode 100644 index 0000000000000..6c353123d5eb6 --- /dev/null +++ b/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/jni/secure_spawn_jni.c @@ -0,0 +1,28 @@ +#include +#include +#include + +JNIEXPORT jstring JNICALL +Java_app_grapheneos_goscompat_securespawn_SecureSpawnCheck_nativeSystemProperty( + JNIEnv* env, jclass clazz, jstring key) { + (void) clazz; + + const char* key_chars = (*env)->GetStringUTFChars(env, key, NULL); + if (key_chars == NULL) { + return NULL; + } + + char value[PROP_VALUE_MAX] = ""; + __system_property_get(key_chars, value); + (*env)->ReleaseStringUTFChars(env, key, key_chars); + return (*env)->NewStringUTF(env, value); +} + +JNIEXPORT jint JNICALL +Java_app_grapheneos_goscompat_securespawn_SecureSpawnCheck_nativeDumpable( + JNIEnv* env, jclass clazz) { + (void) env; + (void) clazz; + + return prctl(PR_GET_DUMPABLE); +} diff --git a/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/SecureSpawnActivity.kt b/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/SecureSpawnActivity.kt new file mode 100644 index 0000000000000..390732d4cc955 --- /dev/null +++ b/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/SecureSpawnActivity.kt @@ -0,0 +1,677 @@ +package app.grapheneos.goscompat.securespawn + +import android.content.ActivityNotFoundException +import android.content.Intent +import android.graphics.Color as AndroidColor +import android.os.Bundle +import android.provider.Settings +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.SystemBarStyle +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import app.grapheneos.goscompat.securespawn.shared.SecureSpawnHiddenApiCheck +import app.grapheneos.goscompat.securespawn.shared.SecureSpawnReflectiveDumpCheck +import app.grapheneos.goscompat.securespawn.shared.SecureSpawnSmapsCheck +import app.grapheneos.goscompat.securespawn.shared.SecureSpawnTestApiCompatCheck +import kotlin.concurrent.thread + +object SecureSpawnUiTags { + const val RUN_CHECK_BUTTON = "secure_spawn_run_check_button" + const val OPEN_SECURITY_SETTINGS_BUTTON = "secure_spawn_open_security_settings_button" + const val SECURE_APP_SPAWNING_STATE = "secure_spawn_setting_state" + const val CHECK_RESULT = "secure_spawn_check_result" + const val HIDDEN_API_REFLECTION_RESULT = "secure_spawn_hidden_api_reflection_result" + const val TEST_API_COMPAT_DEFAULT_RESULT = "secure_spawn_test_api_compat_default_result" + const val ACYCLIC_REFLECTIVE_DUMP_RESULT = "secure_spawn_acyclic_reflective_dump_result" +} + +class SecureSpawnActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge( + statusBarStyle = SystemBarStyle.auto( + AndroidColor.TRANSPARENT, + AndroidColor.TRANSPARENT, + ), + navigationBarStyle = SystemBarStyle.auto( + AndroidColor.TRANSPARENT, + AndroidColor.TRANSPARENT, + ), + ) + + setContent { + val colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme() + var resultState by remember { + mutableStateOf(CheckResultState.NotRun) + } + var settingState by remember { + mutableStateOf(SettingResultState.Loading) + } + var running by remember { mutableStateOf(false) } + + fun refreshSecureAppSpawningState() { + settingState = SettingResultState.Loading + readSecureAppSpawningSetting { result -> + settingState = result + } + } + + fun runCheckFromUi() { + if (running) { + return + } + running = true + runCheck { result -> + if (result is CheckResultState.Success) { + settingState = SettingResultState.Success( + result.result.secureAppSpawningSetting(), + ) + } + resultState = result + running = false + } + } + + LaunchedEffect(Unit) { + refreshSecureAppSpawningState() + } + + MaterialTheme(colorScheme = colorScheme) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + SecureSpawnScreen( + settingState = settingState, + resultState = resultState, + running = running, + onRunCheck = { runCheckFromUi() }, + onOpenSecuritySettings = { openSecuritySettings() }, + ) + } + } + } + } + + private fun openSecuritySettings() { + try { + startActivity(Intent(Settings.ACTION_SECURITY_SETTINGS)) + } catch (e: ActivityNotFoundException) { + Toast.makeText(this, "Security settings unavailable", Toast.LENGTH_SHORT).show() + } + } + + private fun runCheck(onResult: (CheckResultState) -> Unit) { + thread(name = "secure-spawn-check") { + val result = try { + CheckResultState.Success(SecureSpawnCheck.run()) + } catch (t: RuntimeException) { + CheckResultState.Error("${t.javaClass.simpleName}: ${t.message}") + } catch (t: UnsatisfiedLinkError) { + CheckResultState.Error("${t.javaClass.simpleName}: ${t.message}") + } + runOnUiThread { onResult(result) } + } + } + + private fun readSecureAppSpawningSetting(onResult: (SettingResultState) -> Unit) { + thread(name = "secure-spawn-setting") { + val result = try { + SettingResultState.Success(SecureSpawnCheck.secureAppSpawningSetting()) + } catch (t: RuntimeException) { + SettingResultState.Error("${t.javaClass.simpleName}: ${t.message}") + } catch (t: UnsatisfiedLinkError) { + SettingResultState.Error("${t.javaClass.simpleName}: ${t.message}") + } + runOnUiThread { onResult(result) } + } + } +} + +private sealed class SettingResultState { + object Loading : SettingResultState() + data class Success(val setting: SecureSpawnCheck.SecureAppSpawningSetting) : + SettingResultState() + data class Error(val message: String) : SettingResultState() +} + +private sealed class CheckResultState { + object NotRun : CheckResultState() + data class Success(val result: SecureSpawnCheck.Result) : CheckResultState() + data class Error(val message: String) : CheckResultState() +} + +@Composable +private fun SecureSpawnScreen( + settingState: SettingResultState, + resultState: CheckResultState, + running: Boolean, + onRunCheck: () -> Unit, + onOpenSecuritySettings: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .safeDrawingPadding() + .verticalScroll(rememberScrollState()) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(18.dp), + ) { + Text( + text = "GOS Secure Spawn Checks", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = "For manual comparison, change secure app spawning, reboot, then run the " + + "check again.", + style = MaterialTheme.typography.bodyMedium, + ) + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Button( + onClick = onOpenSecuritySettings, + modifier = Modifier.testTag(SecureSpawnUiTags.OPEN_SECURITY_SETTINGS_BUTTON), + ) { + Text("Open security settings") + } + Text( + text = "Security & privacy > Exploit protection > Secure app spawning", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + SecureAppSpawningCard(settingState) + + CheckRunnerCard(running, onRunCheck) + CheckResultContent( + resultState = resultState, + running = running, + modifier = Modifier + .fillMaxWidth() + .testTag(SecureSpawnUiTags.CHECK_RESULT), + ) + } +} + +@Composable +private fun CheckRunnerCard(running: Boolean, onRunCheck: () -> Unit) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Text( + text = "Secure spawn checks", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + ) + Button( + onClick = onRunCheck, + enabled = !running, + modifier = Modifier.testTag(SecureSpawnUiTags.RUN_CHECK_BUTTON), + ) { + Text(if (running) "Running" else "Run checks") + } + } + } +} + +@Composable +private fun CheckResultContent( + resultState: CheckResultState, + running: Boolean, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + when { + running -> ResultCard { + StatusHeader(label = "Status", status = "RUNNING", passed = null) + } + resultState is CheckResultState.NotRun -> + ResultCard { + StatusHeader(label = "Status", status = "NOT RUN", passed = null) + } + resultState is CheckResultState.Error -> ErrorSection(resultState.message) + resultState is CheckResultState.Success -> StructuredResult(resultState.result) + } + } +} + +@Composable +private fun StructuredResult(result: SecureSpawnCheck.Result) { + ProcessStateCard(result.processState()) + RuntimeMemoryAccountingCard(result.androidRuntimeSmaps()) + HiddenApiReflectionCard(result.hiddenApiEnforcement()) + TestApiCompatDefaultCard(result.testApiCompatDefault()) + AcyclicReflectiveDumpCard(result.acyclicReflectiveDump()) +} + +@Composable +private fun SecureAppSpawningCard(settingState: SettingResultState) { + Card( + modifier = Modifier + .fillMaxWidth() + .testTag(SecureSpawnUiTags.SECURE_APP_SPAWNING_STATE), + shape = RoundedCornerShape(8.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = "Secure app spawning", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + ) + when (settingState) { + is SettingResultState.Loading -> + StatusHeader(label = "State", status = "READING", passed = null) + is SettingResultState.Error -> + Text( + text = settingState.message, + style = MaterialTheme.typography.bodyMedium, + ) + is SettingResultState.Success -> { + SettingStateRow( + label = "Current state", + status = if (settingState.setting.enabled()) "ENABLED" else "DISABLED", + enabled = settingState.setting.enabled(), + ) + DetailRow("Property", settingState.setting.rawValue().ifEmpty { "" }) + } + } + } + } +} + +@Composable +private fun ProcessStateCard(processState: SecureSpawnCheck.ProcessState) { + val passed = processState.pid() > 0 && processState.tid() > 0 && + (!processState.hardenedMallocDisabled() || processState.execSpawned()) + ResultCard { + Section(label = "Process state", status = if (passed) "PASS" else "FAIL", passed = passed) { + DetailRow("Exec spawned", processState.execSpawned().toString()) + DetailRow("hardened_malloc disabled", processState.hardenedMallocDisabled().toString()) + DetailRow("PID", processState.pid().toString()) + DetailRow("TID", processState.tid().toString()) + if (!passed) { + Text( + text = "The process state check failed because hardened_malloc was reported " + + "disabled without the process being exec spawned, or because PID/TID " + + "could not be read.", + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } +} + +@Composable +private fun RuntimeMemoryAccountingCard(smaps: SecureSpawnSmapsCheck.AndroidRuntimeSmaps) { + val passed = smaps.sections() > 0 && + smaps.androidRuntimeSections() > 0 && + smaps.isWithinMemoryBounds() + ResultCard { + Section( + label = "Runtime memory accounting", + status = if (passed) "PASS" else "FAIL", + passed = passed, + ) { + Text( + text = runtimeMemoryAccountingSummary(smaps), + style = MaterialTheme.typography.bodyMedium, + ) + DetailRow("Total smaps sections", smaps.sections().toString()) + DetailRow("libandroid_runtime.so sections", smaps.androidRuntimeSections().toString()) + DetailRow("Measured sections", smaps.measuredSections().toString()) + DetailRow("Out-of-bounds sections", smaps.outOfBoundsSections().toString()) + DetailRow("Max libandroid_runtime.so Shared_Clean", + formatBytes(smaps.maxAndroidRuntimeSharedClean())) + DetailRow("Max measured Shared_Clean", formatBytes(smaps.maxMeasuredSharedClean())) + DetailRow("Max out-of-bounds Shared_Clean", + formatBytes(smaps.maxOutOfBoundsSharedClean())) + DetailRow("Shared_Clean limit", formatBytes(smaps.sharedCleanLimit())) + if (smaps.measuredDetails().isNotEmpty()) { + MonospaceBlock("Measured mappings", smaps.measuredDetails()) + } + if (smaps.firstOutOfBoundsHeader().isNotEmpty()) { + DetailRow("First out-of-bounds Shared_Clean", + formatBytes(smaps.firstOutOfBoundsSharedClean())) + DetailRow("First out-of-bounds Shared_Dirty", + formatBytes(smaps.firstOutOfBoundsSharedDirty())) + MonospaceBlock("First out-of-bounds maps line", smaps.firstOutOfBoundsHeader()) + } + } + } +} + +@Composable +private fun HiddenApiReflectionCard( + hiddenApi: SecureSpawnHiddenApiCheck.HiddenApiEnforcement, +) { + val passed = hiddenApi.objectShadowFieldsHidden() + ResultCard( + modifier = Modifier.testTag(SecureSpawnUiTags.HIDDEN_API_REFLECTION_RESULT), + ) { + Section( + label = "Hidden API reflection", + status = if (passed) "PASS" else "FAIL", + passed = passed, + ) { + Text( + text = if (passed) { + "Passed because Object shadow fields were hidden from reflection." + } else { + "Failed because Object shadow fields were visible to reflection." + }, + style = MaterialTheme.typography.bodyMedium, + ) + DetailRow("Exec spawned", hiddenApi.execSpawned().toString()) + DetailRow("Object declared field count", + hiddenApi.objectDeclaredFieldCount().toString()) + DetailRow("shadow\$_klass_ visible", + hiddenApi.objectShadowKlassVisible().toString()) + DetailRow("shadow\$_monitor_ visible", + hiddenApi.objectShadowMonitorVisible().toString()) + MonospaceBlock("Object declared field names", hiddenApi.objectDeclaredFieldNames()) + } + } +} + +@Composable +private fun TestApiCompatDefaultCard( + testApi: SecureSpawnTestApiCompatCheck.TestApiCompat, +) { + val passed = testApi.accessResult().outcome() == + SecureSpawnTestApiCompatCheck.AccessOutcome.ACCESS_DENIED + ResultCard( + modifier = Modifier.testTag(SecureSpawnUiTags.TEST_API_COMPAT_DEFAULT_RESULT), + ) { + Section( + label = "Test API compat default", + status = if (passed) "PASS" else "FAIL", + passed = passed, + ) { + Text( + text = if (passed) { + "Passed because default test API access was denied." + } else { + "Failed because default test API access was not denied." + }, + style = MaterialTheme.typography.bodyMedium, + ) + DetailRow("Exec spawned", testApi.execSpawned().toString()) + DetailRow("ALLOW_TEST_API_ACCESS change ID", + testApi.allowTestApiAccessChangeId().toString()) + DetailRow("Framework compat enabled", testApi.frameworkCompatEnabled()) + DetailRow("Candidate", testApi.accessResult().candidate()) + DetailRow("Outcome", testApi.accessResult().outcome().toString()) + DetailRow("Detail", testApi.accessResult().detail()) + DetailRow("Access allowed", testApi.accessAllowed().toString()) + } + } +} + +@Composable +private fun AcyclicReflectiveDumpCard( + dump: SecureSpawnReflectiveDumpCheck.AcyclicReflectiveDump, +) { + val passed = dump.threadTid() > 0 && dump.fixtureDepth() > 1 && dump.completed() + ResultCard( + modifier = Modifier.testTag(SecureSpawnUiTags.ACYCLIC_REFLECTIVE_DUMP_RESULT), + ) { + Section( + label = "Acyclic reflective dump", + status = if (passed) "PASS" else "FAIL", + passed = passed, + ) { + Text( + text = acyclicReflectiveDumpSummary(dump), + style = MaterialTheme.typography.bodyMedium, + ) + DetailRow("Exec spawned", dump.execSpawned().toString()) + DetailRow("Fixture depth", dump.fixtureDepth().toString()) + DetailRow("Completed", dump.completed().toString()) + DetailRow("Result length", dump.resultLength().toString()) + DetailRow("Thread", dump.threadName().ifEmpty { "" }) + DetailRow("Thread TID", dump.threadTid().toString()) + if (dump.failureClass().isNotEmpty()) { + DetailRow("Failure class", dump.failureClass()) + } + if (dump.failureMessage().isNotEmpty()) { + DetailRow("Failure message", dump.failureMessage()) + } + DetailRow("dumpObject frames", dump.dumpObjectFrames().toString()) + DetailRow("getAllFields frames", dump.getAllFieldsFrames().toString()) + DetailRow("Arrays.toArray frames", dump.arraysToArrayFrames().toString()) + DetailRow("ArrayList.addAll frames", dump.arrayListAddAllFrames().toString()) + DetailRow("Compatibility frames", dump.compatibilityFrames().toString()) + if (dump.stackTraceSample().isNotEmpty()) { + MonospaceBlock("Stack sample", dump.stackTraceSample()) + } + } + } +} + +@Composable +private fun MonospaceBlock(label: String, value: String) { + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Column( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + ) { + Text( + text = value, + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + ), + softWrap = false, + ) + } +} + +private fun acyclicReflectiveDumpSummary( + dump: SecureSpawnReflectiveDumpCheck.AcyclicReflectiveDump, +): String { + if (dump.threadTid() <= 0) { + return "Failed because the reflective dump worker thread did not report a TID." + } + if (dump.fixtureDepth() <= 1) { + return "Failed because the acyclic fixture was not deep enough." + } + if (!dump.completed()) { + return "Failed because the bounded acyclic reflective dump did not complete." + } + return "Passed because the bounded acyclic reflective dump completed depth " + + "${dump.fixtureDepth()} without walking hidden runtime internals." +} + +@Composable +private fun ResultCard( + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + content() + } + } +} + +private fun runtimeMemoryAccountingSummary( + smaps: SecureSpawnSmapsCheck.AndroidRuntimeSmaps, +): String { + if (smaps.sections() <= 0) { + return "Failed because /proc/self/smaps did not contain parseable sections." + } + if (smaps.androidRuntimeSections() <= 0) { + return "Failed because no libandroid_runtime.so section was found in /proc/self/smaps." + } + if (!smaps.isWithinMemoryBounds()) { + return "Failed because ${smaps.outOfBoundsSections()} libandroid_runtime.so section(s) " + + "exceeded the memory accounting bound." + } + return "Passed because libandroid_runtime.so memory accounting stayed within bounds." +} + +@Composable +private fun ErrorSection(message: String) { + ResultCard { + Section(label = "Status", status = "ERROR", passed = false) { + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + ) + } + } +} + +@Composable +private fun Section( + label: String, + status: String? = null, + passed: Boolean? = null, + content: @Composable () -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (status == null) { + Text( + text = label, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + } else { + StatusHeader(label = label, status = status, passed = passed) + } + content() + } +} + +@Composable +private fun StatusHeader(label: String, status: String, passed: Boolean?) { + val statusColor = when (passed) { + true -> MaterialTheme.colorScheme.primary + false -> MaterialTheme.colorScheme.error + null -> MaterialTheme.colorScheme.tertiary + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = status, + modifier = Modifier + .background(statusColor.copy(alpha = 0.12f), RoundedCornerShape(6.dp)) + .padding(horizontal = 10.dp, vertical = 4.dp), + color = statusColor, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + ) + Spacer(modifier = Modifier.width(10.dp)) + Text( + text = label, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + } +} + +@Composable +private fun DetailRow(label: String, value: String) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + ) + } +} + +@Composable +private fun SettingStateRow(label: String, status: String, enabled: Boolean) { + val statusColor = if (enabled) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = status, + modifier = Modifier + .background(statusColor.copy(alpha = 0.12f), RoundedCornerShape(6.dp)) + .padding(horizontal = 10.dp, vertical = 4.dp), + color = statusColor, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + ) + Spacer(modifier = Modifier.width(10.dp)) + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + ) + } +} + +private fun formatBytes(bytes: Long): String { + return "$bytes bytes (${bytes / 1024L} KiB)" +} diff --git a/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/SecureSpawnCheck.java b/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/SecureSpawnCheck.java new file mode 100644 index 0000000000000..ada2d2078cb5e --- /dev/null +++ b/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/SecureSpawnCheck.java @@ -0,0 +1,109 @@ +package app.grapheneos.goscompat.securespawn; + +import android.os.Process; + +import app.grapheneos.goscompat.securespawn.shared.SecureSpawnHiddenApiCheck; +import app.grapheneos.goscompat.securespawn.shared.SecureSpawnReflectiveDumpCheck; +import app.grapheneos.goscompat.securespawn.shared.SecureSpawnSmapsCheck; +import app.grapheneos.goscompat.securespawn.shared.SecureSpawnTestApiCompatCheck; + +public final class SecureSpawnCheck { + private static final String EXEC_SPAWN_PROPERTY = "persist.security.exec_spawn"; + + static { + System.loadLibrary("goscompat_secure_spawn_jni"); + } + + private SecureSpawnCheck() { + } + + public static Result run() { + ProcessState processState = processState(); + return new Result( + secureAppSpawningSetting(), + processState, + androidRuntimeSmaps(), + SecureSpawnHiddenApiCheck.run(processState.execSpawned()), + SecureSpawnTestApiCompatCheck.run(processState.execSpawned()), + SecureSpawnReflectiveDumpCheck.run(processState.execSpawned())); + } + + public static SecureAppSpawningSetting secureAppSpawningSetting() { + return new SecureAppSpawningSetting(nativeSystemProperty(EXEC_SPAWN_PROPERTY)); + } + + public static ProcessState processState() { + return new ProcessState( + System.getenv("IS_EXEC_SPAWNED_APP_PROCESS") != null, + System.getenv("DISABLE_HARDENED_MALLOC") != null, + Process.myPid(), + Process.myTid()); + } + + public static SecureSpawnSmapsCheck.AndroidRuntimeSmaps androidRuntimeSmaps() { + return SecureSpawnSmapsCheck.run(); + } + + public static SecureSpawnHiddenApiCheck.HiddenApiEnforcement hiddenApiEnforcement() { + return SecureSpawnHiddenApiCheck.run(processState().execSpawned()); + } + + public static SecureSpawnTestApiCompatCheck.TestApiCompat testApiCompatDefault() { + return SecureSpawnTestApiCompatCheck.run(processState().execSpawned()); + } + + public static SecureSpawnReflectiveDumpCheck.AcyclicReflectiveDump + acyclicReflectiveDump() { + return SecureSpawnReflectiveDumpCheck.run(processState().execSpawned()); + } + + public static int dumpable() { + return nativeDumpable(); + } + + private static native String nativeSystemProperty(String key); + private static native int nativeDumpable(); + + public record Result( + SecureAppSpawningSetting secureAppSpawningSetting, + ProcessState processState, + SecureSpawnSmapsCheck.AndroidRuntimeSmaps androidRuntimeSmaps, + SecureSpawnHiddenApiCheck.HiddenApiEnforcement hiddenApiEnforcement, + SecureSpawnTestApiCompatCheck.TestApiCompat testApiCompatDefault, + SecureSpawnReflectiveDumpCheck.AcyclicReflectiveDump acyclicReflectiveDump) {} + + public record SecureAppSpawningSetting(String rawValue) { + public boolean enabled() { + if (rawValue().isEmpty()) { + return true; + } + if (rawValue().equals("true") || rawValue().equals("1")) { + return true; + } + if (rawValue().equals("false") || rawValue().equals("0")) { + return false; + } + return true; + } + + @Override + public String toString() { + return "enabled=" + enabled() + + "\nproperty=" + (rawValue().isEmpty() ? "" : rawValue()); + } + } + + public record ProcessState( + boolean execSpawned, + boolean hardenedMallocDisabled, + int pid, + int tid) { + @Override + public String toString() { + return "execSpawned=" + execSpawned() + + "\nhardenedMallocDisabled=" + hardenedMallocDisabled() + + "\npid=" + pid() + + "\ntid=" + tid(); + } + } +} diff --git a/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/SecureSpawnDeviceTest.java b/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/SecureSpawnDeviceTest.java new file mode 100644 index 0000000000000..a3a263576a9a8 --- /dev/null +++ b/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/SecureSpawnDeviceTest.java @@ -0,0 +1,161 @@ +package app.grapheneos.goscompat.securespawn; + +import static com.google.common.truth.Truth.assertWithMessage; + +import android.os.Process; +import android.util.Log; + +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import app.grapheneos.goscompat.securespawn.shared.SecureSpawnDumpableCheck; +import app.grapheneos.goscompat.securespawn.shared.SecureSpawnHiddenApiCheck; +import app.grapheneos.goscompat.securespawn.shared.SecureSpawnReflectiveDumpCheck; +import app.grapheneos.goscompat.securespawn.shared.SecureSpawnSmapsCheck; +import app.grapheneos.goscompat.securespawn.shared.SecureSpawnTestApiCompatCheck; + +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +public final class SecureSpawnDeviceTest { + private static final String TAG = "GosCompatSecureSpawn"; + + @Test + public void execSpawned() { + SecureSpawnCheck.ProcessState result = SecureSpawnCheck.processState(); + assertProcessState(result); + assertWithMessage(failureMessage("expected execSpawned == true", result)) + .that(result.execSpawned()).isTrue(); + assertWithMessage(failureMessage("expected hardenedMallocDisabled == false", result)) + .that(result.hardenedMallocDisabled()).isFalse(); + } + + @Test + public void notExecSpawned() { + SecureSpawnCheck.ProcessState result = SecureSpawnCheck.processState(); + assertProcessState(result); + assertWithMessage(failureMessage("expected execSpawned == false", result)) + .that(result.execSpawned()).isFalse(); + assertWithMessage(failureMessage("expected hardenedMallocDisabled == false", result)) + .that(result.hardenedMallocDisabled()).isFalse(); + } + + @Test + public void hardenedMallocDisabled() { + SecureSpawnCheck.ProcessState result = SecureSpawnCheck.processState(); + assertProcessState(result); + assertWithMessage(failureMessage("expected execSpawned == true", result)) + .that(result.execSpawned()).isTrue(); + assertWithMessage(failureMessage("expected hardenedMallocDisabled == true", result)) + .that(result.hardenedMallocDisabled()).isTrue(); + } + + @Test + public void runtimeMemoryAccountingCheck() { + SecureSpawnSmapsCheck.AndroidRuntimeSmaps result = SecureSpawnSmapsCheck.run(); + Log.i(TAG, "runtimeMemoryAccountingCheck\n" + result); + assertWithMessage(failureMessage("expected sections > 0", result)) + .that(result.sections()).isGreaterThan(0); + assertWithMessage(failureMessage("expected androidRuntimeSections > 0", result)) + .that(result.androidRuntimeSections()).isGreaterThan(0); + assertWithMessage(failureMessage("expected isWithinMemoryBounds == true", result)) + .that(result.isWithinMemoryBounds()).isTrue(); + } + + @Test + public void hiddenApiEnforcementCheck() { + SecureSpawnCheck.ProcessState processState = SecureSpawnCheck.processState(); + SecureSpawnHiddenApiCheck.HiddenApiEnforcement result = + SecureSpawnHiddenApiCheck.run(processState.execSpawned()); + Log.i(TAG, "hiddenApiEnforcementCheck\n" + result); + assertProcessState(processState); + assertWithMessage(failureMessage( + "expected hidden API execSpawned to match process execSpawned", result)) + .that(result.execSpawned()) + .isEqualTo(processState.execSpawned()); + assertWithMessage(failureMessage("expected objectShadowFieldsHidden == true", result)) + .that(result.objectShadowFieldsHidden()).isTrue(); + } + + @Test + public void testApiCompatDefaultCheck() { + testApiCompatCheck("testApiCompatDefaultCheck", false); + } + + @Test + public void testApiCompatDisabledCheck() { + testApiCompatCheck("testApiCompatDisabledCheck", false); + } + + @Test + public void testApiCompatEnabledCheck() { + testApiCompatCheck("testApiCompatEnabledCheck", true); + } + + @Test + public void profileableFromShellDumpableCheck() { + SecureSpawnCheck.ProcessState processState = SecureSpawnCheck.processState(); + SecureSpawnDumpableCheck.DumpableState result = + SecureSpawnDumpableCheck.run(processState.execSpawned()); + Log.i(TAG, "profileableFromShellDumpableCheck\n" + result); + assertProcessState(processState); + assertWithMessage(failureMessage( + "expected dumpable check execSpawned to match process execSpawned", result)) + .that(result.execSpawned()) + .isEqualTo(processState.execSpawned()); + assertWithMessage(failureMessage("expected isDumpable == true", result)) + .that(result.isDumpable()).isTrue(); + } + + @Test + public void acyclicReflectiveDumpCheck() { + SecureSpawnCheck.ProcessState processState = SecureSpawnCheck.processState(); + SecureSpawnReflectiveDumpCheck.AcyclicReflectiveDump result = + SecureSpawnReflectiveDumpCheck.run(processState.execSpawned()); + Log.i(TAG, "acyclicReflectiveDumpCheck\n" + result); + assertProcessState(processState); + assertWithMessage(failureMessage( + "expected reflective dump execSpawned to match process execSpawned", result)) + .that(result.execSpawned()) + .isEqualTo(processState.execSpawned()); + assertWithMessage(failureMessage("expected threadTid > 0", result)) + .that(result.threadTid()).isGreaterThan(0); + assertWithMessage(failureMessage("expected threadTid != Process.myTid()", result)) + .that(result.threadTid()).isNotEqualTo(Process.myTid()); + assertWithMessage(failureMessage("expected fixtureDepth > 1", result)) + .that(result.fixtureDepth()).isGreaterThan(1); + assertWithMessage(failureMessage("expected completed == true", result)) + .that(result.completed()).isTrue(); + assertWithMessage(failureMessage("expected resultLength > 0", result)) + .that(result.resultLength()).isGreaterThan(0); + } + + private static void testApiCompatCheck(String methodName, boolean expectedAccessAllowed) { + SecureSpawnCheck.ProcessState processState = SecureSpawnCheck.processState(); + SecureSpawnTestApiCompatCheck.TestApiCompat result = + SecureSpawnTestApiCompatCheck.run(processState.execSpawned()); + Log.i(TAG, methodName + "\n" + result); + assertProcessState(processState); + assertWithMessage(failureMessage( + "expected test API compat execSpawned to match process execSpawned", result)) + .that(result.execSpawned()) + .isEqualTo(processState.execSpawned()); + SecureSpawnTestApiCompatCheck.AccessOutcome expectedOutcome = expectedAccessAllowed + ? SecureSpawnTestApiCompatCheck.AccessOutcome.ACCESS_ALLOWED + : SecureSpawnTestApiCompatCheck.AccessOutcome.ACCESS_DENIED; + assertWithMessage(failureMessage("expected test API access outcome == " + + expectedOutcome, result)).that(result.accessResult().outcome()) + .isEqualTo(expectedOutcome); + } + + private static void assertProcessState(SecureSpawnCheck.ProcessState result) { + assertWithMessage(failureMessage("expected pid > 0", result)) + .that(result.pid()).isGreaterThan(0); + assertWithMessage(failureMessage("expected tid > 0", result)) + .that(result.tid()).isGreaterThan(0); + } + + private static String failureMessage(String expectation, Object result) { + return expectation + "\n" + result; + } +} diff --git a/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/shared/SecureSpawnDumpableCheck.java b/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/shared/SecureSpawnDumpableCheck.java new file mode 100644 index 0000000000000..bb9e4ff915c30 --- /dev/null +++ b/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/shared/SecureSpawnDumpableCheck.java @@ -0,0 +1,25 @@ +package app.grapheneos.goscompat.securespawn.shared; + +import app.grapheneos.goscompat.securespawn.SecureSpawnCheck; + +public final class SecureSpawnDumpableCheck { + private SecureSpawnDumpableCheck() { + } + + public static DumpableState run(boolean execSpawned) { + return new DumpableState(execSpawned, SecureSpawnCheck.dumpable()); + } + + public record DumpableState(boolean execSpawned, int dumpable) { + public boolean isDumpable() { + return dumpable() == 1; + } + + @Override + public String toString() { + return "execSpawned=" + execSpawned() + + "\ndumpable=" + dumpable() + + "\nisDumpable=" + isDumpable(); + } + } +} diff --git a/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/shared/SecureSpawnHiddenApiCheck.java b/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/shared/SecureSpawnHiddenApiCheck.java new file mode 100644 index 0000000000000..c153e8053c476 --- /dev/null +++ b/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/shared/SecureSpawnHiddenApiCheck.java @@ -0,0 +1,67 @@ +package app.grapheneos.goscompat.securespawn.shared; + +import java.lang.reflect.Field; + +public final class SecureSpawnHiddenApiCheck { + private static final String OBJECT_SHADOW_KLASS_FIELD = "shadow$_klass_"; + private static final String OBJECT_SHADOW_MONITOR_FIELD = "shadow$_monitor_"; + + private SecureSpawnHiddenApiCheck() { + } + + public static HiddenApiEnforcement run(boolean execSpawned) { + Field[] objectFields = Object.class.getDeclaredFields(); + return new HiddenApiEnforcement( + execSpawned, + objectFields.length, + fieldNames(objectFields), + hasField(objectFields, OBJECT_SHADOW_KLASS_FIELD), + hasField(objectFields, OBJECT_SHADOW_MONITOR_FIELD)); + } + + private static boolean hasField(Field[] fields, String name) { + for (Field field : fields) { + if (field.getName().equals(name)) { + return true; + } + } + return false; + } + + private static String fieldNames(Field[] fields) { + if (fields.length == 0) { + return "[]"; + } + + StringBuilder sb = new StringBuilder("["); + for (int i = 0; i < fields.length; i++) { + if (i > 0) { + sb.append(", "); + } + sb.append(fields[i].getName()); + } + return sb.append(']').toString(); + } + + public record HiddenApiEnforcement( + boolean execSpawned, + int objectDeclaredFieldCount, + String objectDeclaredFieldNames, + boolean objectShadowKlassVisible, + boolean objectShadowMonitorVisible) { + public boolean objectShadowFieldsHidden() { + return !objectShadowKlassVisible() + && !objectShadowMonitorVisible(); + } + + @Override + public String toString() { + return "execSpawned=" + execSpawned() + + "\nobjectDeclaredFieldCount=" + objectDeclaredFieldCount() + + "\nobjectDeclaredFieldNames=" + objectDeclaredFieldNames() + + "\nobjectShadowKlassVisible=" + objectShadowKlassVisible() + + "\nobjectShadowMonitorVisible=" + objectShadowMonitorVisible() + + "\nobjectShadowFieldsHidden=" + objectShadowFieldsHidden(); + } + } +} diff --git a/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/shared/SecureSpawnReflectiveDumpCheck.java b/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/shared/SecureSpawnReflectiveDumpCheck.java new file mode 100644 index 0000000000000..44a773fa5b742 --- /dev/null +++ b/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/shared/SecureSpawnReflectiveDumpCheck.java @@ -0,0 +1,483 @@ +package app.grapheneos.goscompat.securespawn.shared; + +import android.os.Process; +import android.util.Log; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +public final class SecureSpawnReflectiveDumpCheck { + private static final String ROOT_CAUSE_LOG_TAG = "GosCompatSecureSpawnRc"; + // Deep enough to produce meaningful recursive traversal symptoms without intentionally + // exhausting the Java thread stack on a correct hidden API path. + private static final int ACYCLIC_FIXTURE_DEPTH = 16; + private static final int STACK_TRACE_SAMPLE_FRAMES = 24; + private static final int ROOT_CAUSE_LOG_LINE_LIMIT = 200; + private static final int MAX_REPORTED_PATH_LENGTH = 240; + private static final long ARRAYS_AS_LIST_TO_ARRAY_CHANGE_ID = 202956589L; + private static final long OVERRIDDEN_THREAD_START_CHANGE_ID = 418924588L; + private static final String ARRAYS_ARRAY_LIST_CLASS = "java.util.Arrays$ArrayList"; + private static final String ARRAY_LIST_CLASS = "java.util.ArrayList"; + private static final String COMPATIBILITY_CLASS = "android.compat.Compatibility"; + private static final AtomicInteger sNextAttemptId = new AtomicInteger(); + + private SecureSpawnReflectiveDumpCheck() { + } + + public static AcyclicReflectiveDump run(boolean execSpawned) { + ReflectiveDumpWorker worker = new ReflectiveDumpWorker( + sNextAttemptId.incrementAndGet(), execSpawned); + Thread thread = new Thread(worker); + thread.start(); + try { + thread.join(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted waiting for reflective dump probe", e); + } + return worker.result(); + } + + private static ReflectiveDumpResult runAcyclicDump(DumpTrace trace) { + try { + String result = dumpObject(new RecursiveRequest(buildNodeChain(ACYCLIC_FIXTURE_DEPTH)), + trace, 0, "request"); + trace.log("completed resultLength=" + result.length()); + return ReflectiveDumpResult.completed(result.length()); + } catch (StackOverflowError e) { + trace.log("stackOverflow message=" + safeString(e.getMessage())); + return ReflectiveDumpResult.failed(e); + } catch (ReflectiveOperationException | RuntimeException e) { + trace.log("failure throwable=" + e.getClass().getName() + + " message=" + safeString(e.getMessage())); + return ReflectiveDumpResult.failed(e); + } + } + + private static Node buildNodeChain(int depth) { + Node node = null; + for (int i = 0; i < depth; i++) { + node = new Node("node", node); + } + return node; + } + + private static List getAllFields(Class cls, DumpTrace trace, int recursionDepth) { + ArrayList fields = new ArrayList<>(); + while (cls != null) { + Field[] declaredFields = cls.getDeclaredFields(); + trace.log("getAllFields recursionDepth=" + recursionDepth + + " class=" + cls.getName() + + " declaredFieldCount=" + declaredFields.length + + " fields=" + describeFields(declaredFields)); + fields.addAll(Arrays.asList(declaredFields)); + cls = cls.getSuperclass(); + } + return fields; + } + + private static String dumpObject(Object obj, DumpTrace trace, int recursionDepth, String path) + throws ReflectiveOperationException { + if (obj == null) { + trace.log("dumpObject recursionDepth=" + recursionDepth + + " path=" + path + + " object=null"); + return ""; + } + trace.log("dumpObject recursionDepth=" + recursionDepth + + " path=" + path + + " class=" + obj.getClass().getName() + + " identity=" + identityString(obj) + + " " + trace.seenInfo(obj, path)); + if (obj instanceof String) { + return (String) obj; + } + if (obj instanceof Number || obj instanceof Boolean) { + return String.valueOf(obj); + } + if (obj instanceof Map) { + Map map = (Map) obj; + StringBuilder sb = new StringBuilder(); + for (Object key : map.keySet()) { + String keyPath = trace.childPath(path, ""); + sb.append(dumpObject(key, trace, recursionDepth + 1, keyPath)); + sb.append(dumpObject(map.get(key), trace, recursionDepth + 1, + trace.childPath(path, ""))); + } + return sb.toString(); + } + + StringBuilder sb = new StringBuilder(); + for (Field field : getAllFields(obj.getClass(), trace, recursionDepth)) { + String fieldPath = trace.childPath(path, field.getName()); + try { + field.setAccessible(true); + trace.log("field recursionDepth=" + recursionDepth + + " path=" + fieldPath + + " owner=" + field.getDeclaringClass().getName() + + " name=" + field.getName() + + " modifiers=" + modifierString(field.getModifiers()) + + " synthetic=" + field.isSynthetic() + + " type=" + field.getType().getName() + + " setAccessible=ok"); + } catch (RuntimeException e) { + trace.log("field recursionDepth=" + recursionDepth + + " path=" + fieldPath + + " owner=" + field.getDeclaringClass().getName() + + " name=" + field.getName() + + " setAccessible=failed:" + + e.getClass().getSimpleName()); + throw e; + } + Object value = field.get(obj); + trace.log("fieldValue recursionDepth=" + recursionDepth + + " path=" + fieldPath + + " valueClass=" + className(value) + + " valueIdentity=" + identityString(value) + + " " + trace.peekSeenInfo(value)); + sb.append(field.getName()); + sb.append('{'); + sb.append(dumpObject(value, trace, recursionDepth + 1, fieldPath)); + sb.append('}'); + } + return sb.toString(); + } + + private static boolean isRootCauseLoggingEnabled() { + return Log.isLoggable(ROOT_CAUSE_LOG_TAG, Log.DEBUG); + } + + private static String describeFields(Field[] fields) { + if (fields.length == 0) { + return "[]"; + } + + StringBuilder sb = new StringBuilder("["); + for (int i = 0; i < fields.length; i++) { + if (i > 0) { + sb.append(", "); + } + Field field = fields[i]; + sb.append(field.getName()) + .append(':') + .append(field.getType().getName()) + .append(":modifiers=") + .append(modifierString(field.getModifiers())) + .append(":synthetic=") + .append(field.isSynthetic()); + } + return sb.append(']').toString(); + } + + private static String modifierString(int modifiers) { + String value = Modifier.toString(modifiers); + return value.isEmpty() ? "" : value; + } + + private static String className(Object obj) { + return obj == null ? "null" : obj.getClass().getName(); + } + + private static String identityString(Object obj) { + if (obj == null) { + return "null"; + } + return "0x" + Integer.toHexString(System.identityHashCode(obj)); + } + + private static String currentTargetSdkVersion() { + try { + Class vmRuntime = Class.forName("dalvik.system.VMRuntime"); + Object runtime = vmRuntime.getMethod("getRuntime").invoke(null); + Object targetSdkVersion = + vmRuntime.getMethod("getTargetSdkVersion").invoke(runtime); + return String.valueOf(targetSdkVersion); + } catch (ReflectiveOperationException | RuntimeException e) { + return "unavailable:" + e.getClass().getSimpleName(); + } + } + + private static String compatChangeEnabled(long changeId) { + try { + Class compatibility = Class.forName("android.compat.Compatibility"); + Object enabled = compatibility.getMethod("isChangeEnabled", long.class) + .invoke(null, changeId); + return String.valueOf(enabled); + } catch (ReflectiveOperationException | RuntimeException e) { + return "unavailable:" + e.getClass().getSimpleName(); + } + } + + private static String safeString(String value) { + return value == null ? "" : value; + } + + private static final class ReflectiveDumpWorker implements Runnable { + private final int mAttemptId; + private final boolean mExecSpawned; + private AcyclicReflectiveDump mResult; + private Throwable mFailure; + + ReflectiveDumpWorker(int attemptId, boolean execSpawned) { + mAttemptId = attemptId; + mExecSpawned = execSpawned; + } + + @Override + public void run() { + DumpTrace trace = DumpTrace.create(mAttemptId); + trace.logWorkerStart(mExecSpawned); + try { + ReflectiveDumpResult dumpResult = runAcyclicDump(trace); + mResult = new AcyclicReflectiveDump( + mExecSpawned, + Thread.currentThread().getName(), + Process.myTid(), + ACYCLIC_FIXTURE_DEPTH, + dumpResult.completed(), + dumpResult.resultLength(), + dumpResult.failureClass(), + dumpResult.failureMessage(), + dumpResult.dumpObjectFrames(), + dumpResult.getAllFieldsFrames(), + dumpResult.arraysToArrayFrames(), + dumpResult.arrayListAddAllFrames(), + dumpResult.compatibilityFrames(), + dumpResult.stackTraceSample()); + } catch (Throwable e) { + trace.log("workerFailure throwable=" + e.getClass().getName() + + " message=" + safeString(e.getMessage())); + mFailure = e; + } + } + + AcyclicReflectiveDump result() { + if (mFailure instanceof RuntimeException) { + throw (RuntimeException) mFailure; + } + if (mFailure instanceof Error) { + throw (Error) mFailure; + } + if (mFailure != null) { + throw new IllegalStateException("Reflective dump probe failed", mFailure); + } + if (mResult == null) { + throw new IllegalStateException("Reflective dump probe did not report a result"); + } + return mResult; + } + } + + private static final class DumpTrace { + private final int mAttemptId; + private final boolean mEnabled; + private final IdentityHashMap mSeen; + private int mLines; + private boolean mTruncated; + + private DumpTrace(int attemptId, boolean enabled) { + mAttemptId = attemptId; + mEnabled = enabled; + mSeen = enabled ? new IdentityHashMap<>() : null; + } + + static DumpTrace create(int attemptId) { + return new DumpTrace(attemptId, isRootCauseLoggingEnabled()); + } + + void logWorkerStart(boolean execSpawned) { + log("workerStart" + + " execSpawnedArg=" + execSpawned + + " envExecSpawned=" + + safeString(System.getenv("IS_EXEC_SPAWNED_APP_PROCESS")) + + " envDisableHardenedMalloc=" + + safeString(System.getenv("DISABLE_HARDENED_MALLOC")) + + " threadName=" + Thread.currentThread().getName() + + " threadTid=" + Process.myTid() + + " pid=" + Process.myPid() + + " targetSdk=" + currentTargetSdkVersion() + + " arraysAsListToArrayChange=" + + compatChangeEnabled(ARRAYS_AS_LIST_TO_ARRAY_CHANGE_ID) + + " overriddenThreadStartChange=" + + compatChangeEnabled(OVERRIDDEN_THREAD_START_CHANGE_ID)); + } + + void log(String message) { + if (!mEnabled) { + return; + } + if (mLines >= ROOT_CAUSE_LOG_LINE_LIMIT) { + if (!mTruncated) { + Log.d(ROOT_CAUSE_LOG_TAG, prefix() + + "logLineLimitReached limit=" + ROOT_CAUSE_LOG_LINE_LIMIT); + mTruncated = true; + } + return; + } + Log.d(ROOT_CAUSE_LOG_TAG, prefix() + message); + mLines++; + } + + String seenInfo(Object obj, String path) { + if (!mEnabled || obj == null) { + return "seenBefore=false"; + } + String firstPath = mSeen.get(obj); + if (firstPath != null) { + return "seenBefore=true firstPath=" + firstPath; + } + mSeen.put(obj, path); + return "seenBefore=false"; + } + + String peekSeenInfo(Object obj) { + if (!mEnabled || obj == null) { + return "seenBefore=false"; + } + String firstPath = mSeen.get(obj); + if (firstPath == null) { + return "seenBefore=false"; + } + return "seenBefore=true firstPath=" + firstPath; + } + + String childPath(String path, String child) { + if (!mEnabled) { + return ""; + } + String next = path.isEmpty() ? child : path + "." + child; + if (next.length() <= MAX_REPORTED_PATH_LENGTH) { + return next; + } + return next.substring(0, MAX_REPORTED_PATH_LENGTH) + "..."; + } + + private String prefix() { + return "attempt=" + mAttemptId + " "; + } + } + + private record ReflectiveDumpResult( + boolean completed, + int resultLength, + String failureClass, + String failureMessage, + int dumpObjectFrames, + int getAllFieldsFrames, + int arraysToArrayFrames, + int arrayListAddAllFrames, + int compatibilityFrames, + String stackTraceSample) { + static ReflectiveDumpResult completed(int resultLength) { + return new ReflectiveDumpResult( + true, resultLength, "", "", 0, 0, 0, 0, 0, ""); + } + + static ReflectiveDumpResult failed(Throwable t) { + StackTraceElement[] stack = t.getStackTrace(); + String checkClass = SecureSpawnReflectiveDumpCheck.class.getName(); + return new ReflectiveDumpResult( + false, + 0, + t.getClass().getName(), + safeString(t.getMessage()), + countFrames(stack, checkClass, "dumpObject"), + countFrames(stack, checkClass, "getAllFields"), + countFrames(stack, ARRAYS_ARRAY_LIST_CLASS, "toArray"), + countFrames(stack, ARRAY_LIST_CLASS, "addAll"), + countFrames(stack, COMPATIBILITY_CLASS, "isChangeEnabled"), + sampleFrames(stack)); + } + + private static int countFrames( + StackTraceElement[] stack, String className, String methodName) { + int count = 0; + for (StackTraceElement frame : stack) { + if (className.equals(frame.getClassName()) + && methodName.equals(frame.getMethodName())) { + count++; + } + } + return count; + } + + private static String sampleFrames(StackTraceElement[] stack) { + StringBuilder sample = new StringBuilder(); + int frameCount = Math.min(stack.length, STACK_TRACE_SAMPLE_FRAMES); + for (int i = 0; i < frameCount; i++) { + if (i > 0) { + sample.append('\n'); + } + sample.append(i).append(' ').append(stack[i]); + } + if (stack.length > frameCount) { + if (sample.length() > 0) { + sample.append('\n'); + } + sample.append("... totalFrames=").append(stack.length); + } + return sample.toString(); + } + } + + private static final class RecursiveRequest { + private final String clientId = "client"; + private final String username = "user"; + private final String password = "secret"; + private final Node root; + + RecursiveRequest(Node root) { + this.root = root; + } + } + + private static final class Node { + private final String name; + private final Node child; + + Node(String name, Node child) { + this.name = name; + this.child = child; + } + } + + public record AcyclicReflectiveDump( + boolean execSpawned, + String threadName, + int threadTid, + int fixtureDepth, + boolean completed, + int resultLength, + String failureClass, + String failureMessage, + int dumpObjectFrames, + int getAllFieldsFrames, + int arraysToArrayFrames, + int arrayListAddAllFrames, + int compatibilityFrames, + String stackTraceSample) { + @Override + public String toString() { + return "execSpawned=" + execSpawned() + + "\nthreadName=" + threadName() + + "\nthreadTid=" + threadTid() + + "\nfixtureDepth=" + fixtureDepth() + + "\ncompleted=" + completed() + + "\nresultLength=" + resultLength() + + "\nfailureClass=" + failureClass() + + "\nfailureMessage=" + failureMessage() + + "\ndumpObjectFrames=" + dumpObjectFrames() + + "\ngetAllFieldsFrames=" + getAllFieldsFrames() + + "\narraysToArrayFrames=" + arraysToArrayFrames() + + "\narrayListAddAllFrames=" + arrayListAddAllFrames() + + "\ncompatibilityFrames=" + compatibilityFrames() + + "\nstackTraceSample=" + stackTraceSample(); + } + } +} diff --git a/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/shared/SecureSpawnSmapsCheck.java b/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/shared/SecureSpawnSmapsCheck.java new file mode 100644 index 0000000000000..ca08ac69c6461 --- /dev/null +++ b/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/shared/SecureSpawnSmapsCheck.java @@ -0,0 +1,211 @@ +package app.grapheneos.goscompat.securespawn.shared; + +import java.io.BufferedReader; +import java.io.FileReader; +import java.io.IOException; + +public final class SecureSpawnSmapsCheck { + private static final String ANDROID_RUNTIME_LIBRARY = "libandroid_runtime.so"; + private static final String SHARED_CLEAN_LABEL = "Shared_Clean:"; + private static final String SHARED_DIRTY_LABEL = "Shared_Dirty:"; + private static final long BYTES_PER_KIB = 1024L; + private static final long ANDROID_RUNTIME_SHARED_CLEAN_LIMIT = 480L * BYTES_PER_KIB; + + private SecureSpawnSmapsCheck() { + } + + public static AndroidRuntimeSmaps run() { + int sections = 0; + int androidRuntimeSections = 0; + int measuredSections = 0; + int outOfBoundsSections = 0; + long maxAndroidRuntimeSharedClean = 0; + long maxMeasuredSharedClean = 0; + long maxOutOfBoundsSharedClean = 0; + StringBuilder measuredDetails = new StringBuilder(); + String firstOutOfBoundsHeader = ""; + long firstOutOfBoundsSharedClean = 0; + long firstOutOfBoundsSharedDirty = 0; + String header = null; + long sharedClean = 0; + long sharedDirty = 0; + + try (BufferedReader reader = new BufferedReader(new FileReader("/proc/self/smaps"))) { + while (true) { + String line = reader.readLine(); + if (line == null || isSmapsHeader(line)) { + if (header != null) { + sections++; + boolean androidRuntime = header.contains(ANDROID_RUNTIME_LIBRARY); + boolean measured = androidRuntime && isMeasuredAndroidRuntimeHeader(header); + boolean outOfBounds = measured + && sharedDirty <= 0 + && sharedClean >= ANDROID_RUNTIME_SHARED_CLEAN_LIMIT; + + if (androidRuntime) { + androidRuntimeSections++; + maxAndroidRuntimeSharedClean = Math.max( + maxAndroidRuntimeSharedClean, sharedClean); + } + + if (measured) { + measuredSections++; + maxMeasuredSharedClean = Math.max( + maxMeasuredSharedClean, sharedClean); + appendMeasuredDetail(measuredDetails, header, sharedClean, + sharedDirty, outOfBounds); + } + + if (outOfBounds) { + outOfBoundsSections++; + maxOutOfBoundsSharedClean = Math.max( + maxOutOfBoundsSharedClean, sharedClean); + if (firstOutOfBoundsHeader.isEmpty()) { + firstOutOfBoundsHeader = header; + firstOutOfBoundsSharedClean = sharedClean; + firstOutOfBoundsSharedDirty = sharedDirty; + } + } + } + + if (line == null) { + break; + } + + header = line; + sharedClean = 0; + sharedDirty = 0; + continue; + } + + if (header == null) { + continue; + } + + long nextSharedClean = parseSmapsValue(line, SHARED_CLEAN_LABEL); + if (nextSharedClean >= 0) { + sharedClean = nextSharedClean; + continue; + } + + long nextSharedDirty = parseSmapsValue(line, SHARED_DIRTY_LABEL); + if (nextSharedDirty >= 0) { + sharedDirty = nextSharedDirty; + } + } + } catch (IOException e) { + throw new IllegalStateException("Unable to read smaps", e); + } + + return new AndroidRuntimeSmaps( + sections, + androidRuntimeSections, + measuredSections, + outOfBoundsSections, + maxAndroidRuntimeSharedClean, + maxMeasuredSharedClean, + maxOutOfBoundsSharedClean, + ANDROID_RUNTIME_SHARED_CLEAN_LIMIT, + measuredDetails.toString(), + firstOutOfBoundsHeader, + firstOutOfBoundsSharedClean, + firstOutOfBoundsSharedDirty); + } + + private static boolean isSmapsHeader(String line) { + int dash = line.indexOf('-'); + int space = line.indexOf(' '); + return dash > 0 && space > dash + 1 + && isHex(line, 0, dash) + && isHex(line, dash + 1, space); + } + + private static boolean isHex(String value, int start, int end) { + for (int i = start; i < end; i++) { + char c = value.charAt(i); + if ((c < '0' || c > '9') + && (c < 'a' || c > 'f') + && (c < 'A' || c > 'F')) { + return false; + } + } + return start < end; + } + + private static long parseSmapsValue(String line, String label) { + if (!line.startsWith(label)) { + return -1; + } + + String value = line.substring(label.length()).trim(); + int separator = value.indexOf(' '); + if (separator >= 0) { + value = value.substring(0, separator); + } + + try { + return Long.parseLong(value) * BYTES_PER_KIB; + } catch (NumberFormatException e) { + return -1; + } + } + + private static boolean isMeasuredAndroidRuntimeHeader(String header) { + String[] fields = header.split("\\s+"); + if (fields.length <= 1) { + return false; + } + + String permissions = fields[1]; + return permissions.length() > 3 + && permissions.charAt(1) != 'w' + && permissions.charAt(2) != 'x' + && permissions.charAt(3) == 'p'; + } + + private static void appendMeasuredDetail( + StringBuilder details, String header, long sharedClean, long sharedDirty, + boolean outOfBounds) { + if (details.length() > 0) { + details.append('\n'); + } + details.append("outOfBounds=").append(outOfBounds) + .append(" Shared_Clean=").append(sharedClean) + .append(" Shared_Dirty=").append(sharedDirty) + .append(" header=").append(header); + } + + public record AndroidRuntimeSmaps( + int sections, + int androidRuntimeSections, + int measuredSections, + int outOfBoundsSections, + long maxAndroidRuntimeSharedClean, + long maxMeasuredSharedClean, + long maxOutOfBoundsSharedClean, + long sharedCleanLimit, + String measuredDetails, + String firstOutOfBoundsHeader, + long firstOutOfBoundsSharedClean, + long firstOutOfBoundsSharedDirty) { + public boolean isWithinMemoryBounds() { + return outOfBoundsSections() == 0; + } + + @Override + public String toString() { + return "sections=" + sections() + + "\nandroidRuntimeSections=" + androidRuntimeSections() + + "\nmeasuredSections=" + measuredSections() + + "\noutOfBoundsSections=" + outOfBoundsSections() + + "\nmaxAndroidRuntimeSharedClean=" + maxAndroidRuntimeSharedClean() + + "\nmaxMeasuredSharedClean=" + maxMeasuredSharedClean() + + "\nmaxOutOfBoundsSharedClean=" + maxOutOfBoundsSharedClean() + + "\nsharedCleanLimit=" + sharedCleanLimit() + + "\nmeasuredDetails=" + measuredDetails() + + "\nfirstOutOfBoundsHeader=" + firstOutOfBoundsHeader() + + "\nfirstOutOfBoundsSharedClean=" + firstOutOfBoundsSharedClean() + + "\nfirstOutOfBoundsSharedDirty=" + firstOutOfBoundsSharedDirty(); + } + } +} diff --git a/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/shared/SecureSpawnTestApiCompatCheck.java b/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/shared/SecureSpawnTestApiCompatCheck.java new file mode 100644 index 0000000000000..3493a3d9ea863 --- /dev/null +++ b/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/shared/SecureSpawnTestApiCompatCheck.java @@ -0,0 +1,238 @@ +package app.grapheneos.goscompat.securespawn.shared; + +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; + +public final class SecureSpawnTestApiCompatCheck { + // From libcore/libart/src/main/java/dalvik/system/VMRuntime.java: + // private static final long ALLOW_TEST_API_ACCESS = 166236554; + public static final long ALLOW_TEST_API_ACCESS_CHANGE_ID = 166236554L; + + // These members are blocked,test-api in out/soong/hiddenapi/hiddenapi-flags.csv. + private static final Candidate[] CANDIDATES = { + /* + * From frameworks/base/core/java/android/app/Activity.java: + * + * @SystemApi(client = SystemApi.Client.MODULE_LIBRARIES) + * @TestApi + * public final boolean addDumpable(@NonNull Dumpable dumpable) + */ + Candidate.method("android.app.Activity", "addDumpable", "android.util.Dumpable"), + Candidate.field("android.app.Activity", "DUMP_ARG_DUMP_DUMPABLE", "--dump-dumpable"), + Candidate.field("android.app.Activity", "DUMP_ARG_LIST_DUMPABLES", "--list-dumpables"), + Candidate.field("android.Manifest$permission", "ACCESSIBILITY_MOTION_EVENT_OBSERVING", + "android.permission.ACCESSIBILITY_MOTION_EVENT_OBSERVING"), + }; + + private SecureSpawnTestApiCompatCheck() { + } + + public static TestApiCompat run(boolean execSpawned) { + return new TestApiCompat( + execSpawned, + ALLOW_TEST_API_ACCESS_CHANGE_ID, + compatChangeEnabled(ALLOW_TEST_API_ACCESS_CHANGE_ID), + probeCandidates()); + } + + private static AccessResult probeCandidates() { + AccessResult lastMissing = null; + for (Candidate candidate : CANDIDATES) { + AccessResult result = probeCandidate(candidate); + if (result.outcome() != AccessOutcome.MEMBER_NOT_PRESENT) { + return result; + } + lastMissing = result; + } + return lastMissing == null + ? AccessResult.failed("", "no candidates configured") + : lastMissing; + } + + private static AccessResult probeCandidate(Candidate candidate) { + Class clazz; + try { + clazz = Class.forName(candidate.className()); + } catch (ClassNotFoundException e) { + return AccessResult.missing(candidate, e); + } catch (LinkageError e) { + return AccessResult.failed(candidate, e); + } + + if (candidate.isMethod()) { + return probeMethodCandidate(clazz, candidate); + } else { + return probeFieldCandidate(clazz, candidate); + } + } + + private static AccessResult probeFieldCandidate(Class clazz, Candidate candidate) { + try { + /* + * Hidden/test API enforcement happens in this lookup. Class.getDeclaredField() is + * native on Android and reaches ART's Class_getDeclaredField(), which throws + * NoSuchFieldException when ShouldDenyAccessToMember(result->GetArtField(), ...) + * rejects the blocked,test-api member. ACCESS_DENIED should exit through the catch + * below before the probe can read the public static field. + */ + Field field; + field = clazz.getDeclaredField(candidate.memberName()); + Object value = field.get(null); + String actualValue = String.valueOf(value); + if (!candidate.expectedValue().equals(actualValue)) { + return AccessResult.failed(candidate, + "expected value " + candidate.expectedValue() + + " but was " + actualValue); + } + return AccessResult.allowed(candidate, "value=" + actualValue); + } catch (NoSuchFieldException e) { + return AccessResult.denied(candidate, e); + } catch (SecurityException e) { + return AccessResult.denied(candidate, e); + } catch (IllegalAccessException | RuntimeException e) { + return AccessResult.denied(candidate, e); + } + } + + private static AccessResult probeMethodCandidate(Class clazz, Candidate candidate) { + Class[] parameterTypes = new Class[candidate.parameterClassNames().length]; + for (int i = 0; i < parameterTypes.length; ++i) { + try { + parameterTypes[i] = Class.forName(candidate.parameterClassNames()[i]); + } catch (ClassNotFoundException e) { + return AccessResult.missing(candidate, e); + } catch (LinkageError e) { + return AccessResult.failed(candidate, e); + } + } + + try { + /* + * Hidden/test API enforcement happens in this lookup. On Android, + * Class.getDeclaredMethod() reaches ART's Class_getDeclaredMethodInternal(), which + * returns null when ShouldDenyAccessToMember(result->GetArtMethod(), ...) rejects the + * blocked,test-api member. Class.getMethod() then translates the null result into + * NoSuchMethodException, so ACCESS_DENIED should exit through the catch below before + * the probe can inspect the method. + */ + Method method; + method = clazz.getDeclaredMethod(candidate.memberName(), parameterTypes); + return AccessResult.allowed(candidate, + "returnType=" + method.getReturnType().getName()); + } catch (NoSuchMethodException e) { + return AccessResult.denied(candidate, e); + } catch (SecurityException e) { + return AccessResult.denied(candidate, e); + } catch (RuntimeException e) { + return AccessResult.denied(candidate, e); + } + } + + private static String compatChangeEnabled(long changeId) { + try { + Class compatibility = Class.forName("android.compat.Compatibility"); + Object enabled = compatibility.getMethod("isChangeEnabled", long.class) + .invoke(null, changeId); + return String.valueOf(enabled); + } catch (ClassNotFoundException | NoSuchMethodException | IllegalAccessException + | InvocationTargetException | RuntimeException e) { + return "unavailable:" + e.getClass().getSimpleName(); + } + } + + private static String exceptionDetail(Throwable t) { + String message = t.getMessage(); + return t.getClass().getSimpleName() + (message == null ? "" : ":" + message); + } + + private record Candidate( + String className, + String memberName, + String expectedValue, + String[] parameterClassNames) { + static Candidate field(String className, String fieldName, String expectedValue) { + return new Candidate(className, fieldName, expectedValue, null); + } + + static Candidate method(String className, String methodName, + String... parameterClassNames) { + return new Candidate(className, methodName, null, parameterClassNames); + } + + boolean isMethod() { + return parameterClassNames() != null; + } + + @Override + public String toString() { + if (!isMethod()) { + return className() + "#" + memberName(); + } + + return className() + "#" + memberName() + "(" + + String.join(",", parameterClassNames()) + ")"; + } + } + + public enum AccessOutcome { + ACCESS_ALLOWED, + ACCESS_DENIED, + MEMBER_NOT_PRESENT, + ACCESS_FAILED, + } + + public record AccessResult( + String candidate, + AccessOutcome outcome, + String detail) { + static AccessResult allowed(Candidate candidate, String detail) { + return new AccessResult(candidate.toString(), AccessOutcome.ACCESS_ALLOWED, + detail); + } + + static AccessResult denied(Candidate candidate, Throwable t) { + return new AccessResult(candidate.toString(), AccessOutcome.ACCESS_DENIED, + exceptionDetail(t)); + } + + static AccessResult missing(Candidate candidate, Throwable t) { + return new AccessResult(candidate.toString(), AccessOutcome.MEMBER_NOT_PRESENT, + exceptionDetail(t)); + } + + static AccessResult failed(Candidate candidate, Throwable t) { + return new AccessResult(candidate.toString(), AccessOutcome.ACCESS_FAILED, + exceptionDetail(t)); + } + + static AccessResult failed(Candidate candidate, String detail) { + return new AccessResult(candidate.toString(), AccessOutcome.ACCESS_FAILED, detail); + } + + static AccessResult failed(String candidate, String detail) { + return new AccessResult(candidate, AccessOutcome.ACCESS_FAILED, detail); + } + } + + public record TestApiCompat( + boolean execSpawned, + long allowTestApiAccessChangeId, + String frameworkCompatEnabled, + AccessResult accessResult) { + public boolean accessAllowed() { + return accessResult().outcome() == AccessOutcome.ACCESS_ALLOWED; + } + + @Override + public String toString() { + return "execSpawned=" + execSpawned() + + "\nallowTestApiAccessChangeId=" + allowTestApiAccessChangeId() + + "\nframeworkCompatEnabled=" + frameworkCompatEnabled() + + "\ncandidate=" + accessResult().candidate() + + "\noutcome=" + accessResult().outcome() + + "\ndetail=" + accessResult().detail() + + "\naccessAllowed=" + accessAllowed(); + } + } +} diff --git a/tests/GosCompatTests/GosCompatSecureSpawnTests/README.md b/tests/GosCompatTests/GosCompatSecureSpawnTests/README.md new file mode 100644 index 0000000000000..61191a7b42b68 --- /dev/null +++ b/tests/GosCompatTests/GosCompatSecureSpawnTests/README.md @@ -0,0 +1,24 @@ +# GosCompatSecureSpawnTests + +This module verifies the app process startup path selected by secure app spawning and by app +compatibility flags that require exec spawning. + +The package under test is `GosCompatSecureSpawnApp`, not `GosCompatCheckApp`. Exec spawning is +disabled for packages with `ApplicationInfo.FLAG_DEBUGGABLE`, and `GosCompatCheckApp` is intentionally +debuggable because the existing GosCompat modules use `run-as app.grapheneos.goscompat.checks` to read +result files from its app data directory. Making that shared helper non-debuggable would require +reworking those result collection paths or splitting the existing modules first, which is more churn +than this regression test needs. + +When `adb root` is available, then tests will set the secure app spawning to the needed state for +those tests and reboot. After those tests finish, the test will reset to the original secure app +spawning state and do another reboot. If `adb root` is not available, then the secure app spawning +prop cannot be set by shell, so the tests will fail the assumption. If secure app spawning state is +already set to the state needed for the test, no prop changes + reboot is needed and tests will just +run. + +Run the module directly with: + +```sh +atest GosCompatSecureSpawnTests +``` diff --git a/tests/GosCompatTests/GosCompatSecureSpawnTests/src/app/grapheneos/goscompat/securespawn/tests/SecureSpawnDisabledHostTest.java b/tests/GosCompatTests/GosCompatSecureSpawnTests/src/app/grapheneos/goscompat/securespawn/tests/SecureSpawnDisabledHostTest.java new file mode 100644 index 0000000000000..7fe58100f49e2 --- /dev/null +++ b/tests/GosCompatTests/GosCompatSecureSpawnTests/src/app/grapheneos/goscompat/securespawn/tests/SecureSpawnDisabledHostTest.java @@ -0,0 +1,51 @@ +package app.grapheneos.goscompat.securespawn.tests; + +import android.content.pm.GosPackageStateFlag; + +import com.android.tradefed.invoker.TestInformation; +import com.android.tradefed.testtype.DeviceJUnit4ClassRunner; +import com.android.tradefed.testtype.junit4.AfterClassWithInfo; +import com.android.tradefed.testtype.junit4.BeforeClassWithInfo; + +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(DeviceJUnit4ClassRunner.class) +public final class SecureSpawnDisabledHostTest extends SecureSpawnHostTestBase { + private static ExecSpawningClassState sExecSpawningState; + + @BeforeClassWithInfo + public static void beforeClass(TestInformation testInfo) throws Exception { + sExecSpawningState = captureExecSpawningState(testInfo, false); + sExecSpawningState = enterExecSpawningMode(testInfo, sExecSpawningState, false); + } + + @AfterClassWithInfo + public static void afterClass(TestInformation testInfo) throws Exception { + restoreExecSpawningMode(testInfo, sExecSpawningState); + } + + @Test + public void secureAppSpawningDisabledUsesZygoteInit() throws Exception { + resetPackageState(); + runDeviceTest("notExecSpawned"); + } + + @Test + public void disabledHardenedMallocUsesExecInit() throws Exception { + resetPackageState(); + editPackageState( + new int[] { GosPackageStateFlag.USE_HARDENED_MALLOC_NON_DEFAULT }, + new int[] { GosPackageStateFlag.USE_HARDENED_MALLOC }); + runDeviceTest("hardenedMallocDisabled"); + } + + @Test + public void exploitCompatibilityModeUsesExecInit() throws Exception { + resetPackageState(); + editPackageState( + new int[] { GosPackageStateFlag.ENABLE_EXPLOIT_PROTECTION_COMPAT_MODE }, + new int[0]); + runDeviceTest("hardenedMallocDisabled"); + } +} diff --git a/tests/GosCompatTests/GosCompatSecureSpawnTests/src/app/grapheneos/goscompat/securespawn/tests/SecureSpawnEnabledHostTest.java b/tests/GosCompatTests/GosCompatSecureSpawnTests/src/app/grapheneos/goscompat/securespawn/tests/SecureSpawnEnabledHostTest.java new file mode 100644 index 0000000000000..62e4ac258f9dc --- /dev/null +++ b/tests/GosCompatTests/GosCompatSecureSpawnTests/src/app/grapheneos/goscompat/securespawn/tests/SecureSpawnEnabledHostTest.java @@ -0,0 +1,31 @@ +package app.grapheneos.goscompat.securespawn.tests; + +import com.android.tradefed.invoker.TestInformation; +import com.android.tradefed.testtype.DeviceJUnit4ClassRunner; +import com.android.tradefed.testtype.junit4.AfterClassWithInfo; +import com.android.tradefed.testtype.junit4.BeforeClassWithInfo; + +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(DeviceJUnit4ClassRunner.class) +public final class SecureSpawnEnabledHostTest extends SecureSpawnHostTestBase { + private static ExecSpawningClassState sExecSpawningState; + + @BeforeClassWithInfo + public static void beforeClass(TestInformation testInfo) throws Exception { + sExecSpawningState = captureExecSpawningState(testInfo, true); + sExecSpawningState = enterExecSpawningMode(testInfo, sExecSpawningState, true); + } + + @AfterClassWithInfo + public static void afterClass(TestInformation testInfo) throws Exception { + restoreExecSpawningMode(testInfo, sExecSpawningState); + } + + @Test + public void secureAppSpawningUsesExecInit() throws Exception { + resetPackageState(); + runDeviceTest("execSpawned"); + } +} diff --git a/tests/GosCompatTests/GosCompatSecureSpawnTests/src/app/grapheneos/goscompat/securespawn/tests/SecureSpawnHostTestBase.java b/tests/GosCompatTests/GosCompatSecureSpawnTests/src/app/grapheneos/goscompat/securespawn/tests/SecureSpawnHostTestBase.java new file mode 100644 index 0000000000000..c4bbbeb6f6756 --- /dev/null +++ b/tests/GosCompatTests/GosCompatSecureSpawnTests/src/app/grapheneos/goscompat/securespawn/tests/SecureSpawnHostTestBase.java @@ -0,0 +1,427 @@ +package app.grapheneos.goscompat.securespawn.tests; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assume.assumeTrue; + +import android.content.pm.GosPackageStateFlag; + +import com.android.tradefed.device.DeviceNotAvailableException; +import com.android.tradefed.device.ITestDevice; +import com.android.tradefed.invoker.TestInformation; +import com.android.tradefed.result.ByteArrayInputStreamSource; +import com.android.tradefed.result.LogDataType; +import com.android.tradefed.testtype.DeviceJUnit4ClassRunner.TestLogData; +import com.android.tradefed.testtype.junit4.BaseHostJUnit4Test; +import com.android.tradefed.testtype.junit4.DeviceTestRunOptions; +import com.android.tradefed.util.CommandResult; + +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +import java.nio.charset.StandardCharsets; + +abstract class SecureSpawnHostTestBase extends BaseHostJUnit4Test { + private static final String PACKAGE_NAME = "app.grapheneos.goscompat.securespawn"; + private static final String PROFILEABLE_PACKAGE_NAME = + "app.grapheneos.goscompat.securespawn.profileable"; + private static final String TEST_CLASS = + "app.grapheneos.goscompat.securespawn.SecureSpawnDeviceTest"; + private static final String EXEC_SPAWN_PROPERTY = "persist.security.exec_spawn"; + private static final String LOG_TAG = "GosCompatSecureSpawn"; + private static final String ROOT_CAUSE_LOG_TAG = "GosCompatSecureSpawnRc"; + private static final String ROOT_CAUSE_LOG_TAG_PROPERTY = "log.tag." + ROOT_CAUSE_LOG_TAG; + private static final String MEMORY_ACCOUNTING_METHOD = + "runtimeMemoryAccountingCheck"; + private static final String HIDDEN_API_METHOD = + "hiddenApiEnforcementCheck"; + private static final String TEST_API_COMPAT_DEFAULT_METHOD = + "testApiCompatDefaultCheck"; + private static final String TEST_API_COMPAT_DISABLED_METHOD = + "testApiCompatDisabledCheck"; + private static final String TEST_API_COMPAT_ENABLED_METHOD = + "testApiCompatEnabledCheck"; + private static final String PROFILEABLE_DUMPABLE_METHOD = + "profileableFromShellDumpableCheck"; + private static final String ACYCLIC_REFLECTIVE_DUMP_METHOD = + "acyclicReflectiveDumpCheck"; + // From libcore/libart/src/main/java/dalvik/system/VMRuntime.java: + // private static final long ALLOW_TEST_API_ACCESS = 166236554; + private static final long ALLOW_TEST_API_ACCESS_CHANGE_ID = 166236554L; + + @Rule + public final TestLogData mLogs = new TestLogData(); + + @Before + public void setUp() throws Exception { + resetCompatState(); + resetPackageState(); + } + + @After + public void tearDown() throws Exception { + resetCompatState(); + resetPackageState(); + shell("am force-stop " + PACKAGE_NAME); + shell("am force-stop " + PROFILEABLE_PACKAGE_NAME); + } + + @Test + public void runtimeMemoryAccountingCheck() throws Exception { + runCheckCase(MEMORY_ACCOUNTING_METHOD); + } + + @Test + public void hiddenApiEnforcementCheck() throws Exception { + runCheckCase(HIDDEN_API_METHOD); + } + + @Test + public void testApiCompatDefaultCheck() throws Exception { + runTestApiCompatCheckCase(TEST_API_COMPAT_DEFAULT_METHOD, CompatOverride.DEFAULT); + } + + @Test + public void testApiCompatDisabledCheck() throws Exception { + runTestApiCompatCheckCase(TEST_API_COMPAT_DISABLED_METHOD, CompatOverride.DISABLED); + } + + @Test + public void testApiCompatEnabledCheck() throws Exception { + runTestApiCompatCheckCase(TEST_API_COMPAT_ENABLED_METHOD, CompatOverride.ENABLED); + } + + @Test + public void profileableFromShellDumpableCheck() throws Exception { + runProfileableCheckCase(PROFILEABLE_DUMPABLE_METHOD); + } + + @Test + public void acyclicReflectiveDumpCheck() throws Exception { + runCheckCase(ACYCLIC_REFLECTIVE_DUMP_METHOD); + } + + protected void runDeviceTest(String methodName) throws Exception { + runDeviceTest(PACKAGE_NAME, methodName); + } + + private void runDeviceTest(String packageName, String methodName) throws Exception { + boolean collectResultLog = resultLogName(methodName) != null; + boolean collectRootCauseLog = ACYCLIC_REFLECTIVE_DUMP_METHOD.equals(methodName); + String originalRootCauseLogTag = null; + try { + if (collectRootCauseLog) { + originalRootCauseLogTag = shell("getprop " + ROOT_CAUSE_LOG_TAG_PROPERTY).trim(); + setRootCauseLogTag("DEBUG"); + } + + shell("am force-stop " + packageName); + if (collectResultLog) { + shell("logcat -c"); + } + DeviceTestRunOptions options = new DeviceTestRunOptions(packageName); + options.setTestClassName(TEST_CLASS); + options.setTestMethodName(methodName); + if (isTestApiCompatMethod(methodName)) { + // Adds --no-test-api-access so ALLOW_TEST_API_ACCESS is the deciding signal. + options.setDisableTestApiCheck(false); + } + boolean passed = false; + AssertionError deviceAssertion = null; + try { + passed = runDeviceTests(options); + } catch (AssertionError e) { + deviceAssertion = e; + } catch (DeviceNotAvailableException e) { + throw new IllegalStateException(e); + } + if (collectResultLog) { + try { + logCheckResult(packageName, methodName); + } catch (AssertionError | Exception e) { + if (deviceAssertion == null) { + throw e; + } + deviceAssertion.addSuppressed(e); + } + } + if (deviceAssertion != null) { + throw deviceAssertion; + } + assertTrue(methodName, passed); + } finally { + if (collectRootCauseLog) { + shell("am force-stop " + packageName); + restoreRootCauseLogTag(originalRootCauseLogTag); + } + } + } + + protected void resetPackageState() throws Exception { + editPackageState( + new int[0], + new int[] { + GosPackageStateFlag.ENABLE_EXPLOIT_PROTECTION_COMPAT_MODE, + GosPackageStateFlag.USE_HARDENED_MALLOC_NON_DEFAULT, + GosPackageStateFlag.USE_HARDENED_MALLOC, + GosPackageStateFlag.USE_EXTENDED_VA_SPACE_NON_DEFAULT, + GosPackageStateFlag.USE_EXTENDED_VA_SPACE, + }); + } + + // TODO: Refactor this when HardeningTests get refactored + protected void editPackageState(int[] addFlags, int[] clearFlags) throws Exception { + StringBuilder command = new StringBuilder("pm edit-gos-package-state ") + .append(PACKAGE_NAME) + .append(' ') + .append(getDevice().getCurrentUser()); + for (int flag : addFlags) { + command.append(" add-flag ").append(flag); + } + for (int flag : clearFlags) { + command.append(" clear-flag ").append(flag); + } + command.append(" set-kill-uid-after-apply true"); + shell(command.toString()); + } + + protected static ExecSpawningClassState captureExecSpawningState( + TestInformation testInfo, boolean enabled) throws Exception { + ITestDevice device = testInfo.getDevice(); + ExecSpawningSetting original = readExecSpawning(device); + return new ExecSpawningClassState(original, original.enabled() != enabled, false); + } + + protected static ExecSpawningClassState enterExecSpawningMode( + TestInformation testInfo, ExecSpawningClassState state, boolean enabled) + throws Exception { + if (!state.needsChange()) { + return state; + } + + ITestDevice device = testInfo.getDevice(); + assumeTrue("adb root is needed to change " + EXEC_SPAWN_PROPERTY, + device.enableAdbRoot()); + assumeTrue(EXEC_SPAWN_PROPERTY + "=" + (enabled ? "1" : "0"), + setExecSpawning(device, enabled ? "1" : "0")); + state.markChanged(); + device.reboot(); + assertEquals(enabled, readExecSpawning(device).enabled()); + return state; + } + + protected static void restoreExecSpawningMode( + TestInformation testInfo, ExecSpawningClassState state) throws Exception { + if (state == null || !state.changed()) { + return; + } + + ITestDevice device = testInfo.getDevice(); + assertTrue("adb root", device.enableAdbRoot()); + assertTrue(EXEC_SPAWN_PROPERTY + "=" + state.original().rawValue(), + setExecSpawning(device, state.original().rawValue())); + device.reboot(); + assertEquals(state.original(), readExecSpawning(device)); + } + + protected static final class ExecSpawningClassState { + private final ExecSpawningSetting mOriginal; + private final boolean mNeedsChange; + private boolean mChanged; + + ExecSpawningClassState( + ExecSpawningSetting original, + boolean needsChange, + boolean changed) { + mOriginal = original; + mNeedsChange = needsChange; + mChanged = changed; + } + + ExecSpawningSetting original() { + return mOriginal; + } + + boolean needsChange() { + return mNeedsChange; + } + + boolean changed() { + return mChanged; + } + + void markChanged() { + mChanged = true; + } + } + + private void runCheckCase(String methodName) throws Exception { + try { + resetPackageState(); + runDeviceTest(methodName); + } catch (AssertionError | RuntimeException e) { + throw new AssertionError(methodName, e); + } + } + + private void runTestApiCompatCheckCase(String methodName, CompatOverride compatOverride) + throws Exception { + assumeCompatOverrideSupported(compatOverride); + try { + resetPackageState(); + setTestApiCompatOverride(compatOverride); + runDeviceTest(methodName); + } catch (AssertionError | RuntimeException e) { + throw new AssertionError(methodName, e); + } finally { + resetCompatState(); + } + } + + private void runProfileableCheckCase(String methodName) throws Exception { + try { + runDeviceTest(PROFILEABLE_PACKAGE_NAME, methodName); + } catch (AssertionError | RuntimeException e) { + throw new AssertionError(methodName, e); + } + } + + private String shell(String command) throws Exception { + return shell(getDevice(), command); + } + + private void logCheckResult(String packageName, String methodName) throws Exception { + ExecSpawningSetting setting = readExecSpawning(getDevice()); + String logcatTags = LOG_TAG + ":I"; + if (ACYCLIC_REFLECTIVE_DUMP_METHOD.equals(methodName)) { + logcatTags += " " + ROOT_CAUSE_LOG_TAG + ":D"; + } + String logcat = shell("logcat -d -v threadtime -s " + logcatTags + " '*:S'"); + String mode = setting.enabled() ? "enabled" : "disabled"; + String logName = resultLogName(methodName); + String report = "secureAppSpawning=" + setting.enabled() + + "\nproperty=" + (setting.rawValue().isEmpty() ? "" : setting.rawValue()) + + "\npackage=" + packageName + + "\nmethod=" + methodName + + "\n\n" + + logcat; + try (ByteArrayInputStreamSource source = + new ByteArrayInputStreamSource(report.getBytes(StandardCharsets.UTF_8))) { + mLogs.addTestLog("goscompat_secure_spawn_" + logName + "_" + mode, + LogDataType.TEXT, source); + } + } + + private static String resultLogName(String methodName) { + if (MEMORY_ACCOUNTING_METHOD.equals(methodName)) { + return "memory_accounting"; + } + if (HIDDEN_API_METHOD.equals(methodName)) { + return "hidden_api"; + } + if (TEST_API_COMPAT_DEFAULT_METHOD.equals(methodName)) { + return "test_api_compat_default"; + } + if (TEST_API_COMPAT_DISABLED_METHOD.equals(methodName)) { + return "test_api_compat_disabled"; + } + if (TEST_API_COMPAT_ENABLED_METHOD.equals(methodName)) { + return "test_api_compat_enabled"; + } + if (PROFILEABLE_DUMPABLE_METHOD.equals(methodName)) { + return "profileable_dumpable"; + } + if (ACYCLIC_REFLECTIVE_DUMP_METHOD.equals(methodName)) { + return "acyclic_reflective_dump"; + } + return null; + } + + private static boolean isTestApiCompatMethod(String methodName) { + return TEST_API_COMPAT_DEFAULT_METHOD.equals(methodName) + || TEST_API_COMPAT_DISABLED_METHOD.equals(methodName) + || TEST_API_COMPAT_ENABLED_METHOD.equals(methodName); + } + + private void setTestApiCompatOverride(CompatOverride compatOverride) throws Exception { + setCompatChange(compatOverride); + } + + private void assumeCompatOverrideSupported(CompatOverride compatOverride) throws Exception { + if (compatOverride == CompatOverride.DEFAULT) { + return; + } + + String buildType = shell("getprop ro.build.type").trim(); + assumeTrue("compat overrides for non-debuggable apps require a non-user build" + + " (ro.build.type=" + buildType + ")", + !"user".equals(buildType)); + } + + private void resetCompatState() throws Exception { + setCompatChange(CompatOverride.DEFAULT); + } + + private void setCompatChange(CompatOverride compatOverride) throws Exception { + shell("am compat " + compatOverride.command() + " " + + ALLOW_TEST_API_ACCESS_CHANGE_ID + " " + PACKAGE_NAME); + } + + private enum CompatOverride { + DEFAULT("reset"), + DISABLED("disable"), + ENABLED("enable"); + + private final String mCommand; + + CompatOverride(String command) { + mCommand = command; + } + + String command() { + return mCommand; + } + } + + private void restoreRootCauseLogTag(String value) throws Exception { + setRootCauseLogTag(value == null ? "" : value); + } + + private void setRootCauseLogTag(String value) throws Exception { + assertTrue(ROOT_CAUSE_LOG_TAG_PROPERTY + "=" + + (value.isEmpty() ? "" : value), + getDevice().setProperty(ROOT_CAUSE_LOG_TAG_PROPERTY, value)); + } + + private static String shell(ITestDevice device, String command) throws Exception { + CommandResult result = device.executeShellV2Command(command); + assertEquals(result.toString(), 0L, (long) result.getExitCode()); + return result.getStdout() == null ? "" : result.getStdout(); + } + + private static ExecSpawningSetting readExecSpawning(ITestDevice device) throws Exception { + String value = shell(device, "getprop " + EXEC_SPAWN_PROPERTY).trim(); + return new ExecSpawningSetting(value, parseExecSpawning(value)); + } + + private static boolean setExecSpawning(ITestDevice device, String value) throws Exception { + return device.setProperty(EXEC_SPAWN_PROPERTY, value); + } + + private static boolean parseExecSpawning(String value) { + if (value.isEmpty()) { + return true; + } + if (value.equals("true") || value.equals("1")) { + return true; + } + if (value.equals("false") || value.equals("0")) { + return false; + } + return true; + } + + protected record ExecSpawningSetting(String rawValue, boolean enabled) {} +} diff --git a/tests/GosCompatTests/README.md b/tests/GosCompatTests/README.md index 188dae45d722b..4edfe7b9fbed8 100644 --- a/tests/GosCompatTests/README.md +++ b/tests/GosCompatTests/README.md @@ -1,6 +1,6 @@ # GosCompatTests -These tests are generally shapped as non-privileged apps in order to regression test compatability +These tests are generally shapped as non-privileged apps in order to regression test compatability with patterns or code used in actual apps. `GosCompatCheckApp` is a standalone helper app with a manual UI. See subdirectories for descriptions @@ -12,8 +12,9 @@ You can run tests from the checkout root via this directory's `TEST_MAPPING`: atest --test-mapping frameworks/base/tests/GosCompatTests:gos_postsubmit ``` -Generally, the device should be unlocked and on user 0 while the tests are running. The tests can -be run on user builds both via `atest` and using the standalone UI in `GosCompatCheckApp`. +Generally, the device should be unlocked and on user 0 while the tests are running. Most tests can +be run on user builds both via `atest` and using the standalone UI in `GosCompatCheckApp`. Host +modules that mutate persistent device or package state can require adb root. Alternatively, view the `TEST_MAPPING` file or Android.bp files and run test modules directly with `atest`. diff --git a/tests/GosCompatTests/TEST_MAPPING b/tests/GosCompatTests/TEST_MAPPING index 2e625181ca3c4..3c521642e83a1 100644 --- a/tests/GosCompatTests/TEST_MAPPING +++ b/tests/GosCompatTests/TEST_MAPPING @@ -11,6 +11,10 @@ }, { "name": "GosCompatWifiScanTimeoutTests" + }, + // Needs adb root to set secure spawning prop, else ASSUMPTION_FAILED for tests with wrong state + { + "name": "GosCompatSecureSpawnTests" } ] } diff --git a/tests/GosCompatTests/common/src/app/grapheneos/goscompat/checks/GosCompatContract.java b/tests/GosCompatTests/common/src/app/grapheneos/goscompat/checks/GosCompatContract.java index 44931627f4b4c..526f9785c3848 100644 --- a/tests/GosCompatTests/common/src/app/grapheneos/goscompat/checks/GosCompatContract.java +++ b/tests/GosCompatTests/common/src/app/grapheneos/goscompat/checks/GosCompatContract.java @@ -181,6 +181,14 @@ private DmaBufRelease() { } } + public static final class SecureSpawn { + public static final String PACKAGE_NAME = "app.grapheneos.goscompat.securespawn"; + public static final String ACTIVITY_CLASS = PACKAGE_NAME + ".SecureSpawnActivity"; + + private SecureSpawn() { + } + } + private GosCompatContract() { } }