From 54952d31ebaf37f0e8bc91a9d8ce5030327212e3 Mon Sep 17 00:00:00 2001 From: inthewaves Date: Fri, 30 Oct 2026 16:06:13 -0700 Subject: [PATCH 1/5] SecureSpawnTests: consolidate process status checks Consolidate process status checks into six host tests, one per process type. Each captures /proc status with exec spawning disabled and enabled, reusing the same capture for explicit expectations and a broad comparison of stable fields. This reduces repeated process launches while extending coverage to fields without dedicated assertions. Add reusable helpers for checking expected field values, integer lists and signal bits. Explicit expectations also catch regressions that make both modes report the same incorrect values, which would pass the comparison between modes. Compare fields across modes by default, with documented exclusions for values that vary between launches and normalization for allocated isolated credentials. Collect failures across both modes and all checks, reporting checked or differing values in a compact summary with diagnostic traces attached separately. Test: atest GosCompatSecureSpawnTests --- .../GosCompatSecureSpawnTests/Android.bp | 2 + .../AndroidManifest.xml | 32 ++- .../securespawn/IManagedProcessProbe.aidl | 5 + .../securespawn/INativeSignalProbe.aidl | 2 +- .../jni/native_signal_probe.cpp | 5 +- .../securespawn/FailureCollector.java | 198 ++++++++++++++ .../ManagedAppZygoteProbeService.java | 4 + .../ManagedIsolatedProbeService.java | 4 + .../ManagedProcessProbeService.java | 20 ++ .../goscompat/securespawn/ProcessStatus.java | 73 +++++ .../securespawn/SecureSpawnDeviceTest.java | 258 +++++++++++++++++- .../securespawn/StatusComparison.java | 130 +++++++++ .../securespawn/WebViewRendererActivity.java | 26 ++ .../GosCompatSecureSpawnTests/README.md | 50 ++++ .../tests/SecureSpawnDisabledHostTest.java | 21 +- .../tests/SecureSpawnEnabledHostTest.java | 23 +- .../tests/SecureSpawnHostTestBase.java | 256 ++++++++--------- .../tests/SecureSpawnModeHostTestBase.java | 96 +++++++ .../SecureSpawnProcessStatusHostTest.java | 75 +++++ 19 files changed, 1098 insertions(+), 182 deletions(-) create mode 100644 tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/aidl/app/grapheneos/goscompat/securespawn/IManagedProcessProbe.aidl create mode 100644 tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/FailureCollector.java create mode 100644 tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/ManagedAppZygoteProbeService.java create mode 100644 tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/ManagedIsolatedProbeService.java create mode 100644 tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/ManagedProcessProbeService.java create mode 100644 tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/ProcessStatus.java create mode 100644 tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/StatusComparison.java create mode 100644 tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/WebViewRendererActivity.java create mode 100644 tests/GosCompatTests/GosCompatSecureSpawnTests/src/app/grapheneos/goscompat/securespawn/tests/SecureSpawnModeHostTestBase.java create mode 100644 tests/GosCompatTests/GosCompatSecureSpawnTests/src/app/grapheneos/goscompat/securespawn/tests/SecureSpawnProcessStatusHostTest.java diff --git a/tests/GosCompatTests/GosCompatSecureSpawnTests/Android.bp b/tests/GosCompatTests/GosCompatSecureSpawnTests/Android.bp index 21dea19e82f8e..bb1539463d1c8 100644 --- a/tests/GosCompatTests/GosCompatSecureSpawnTests/Android.bp +++ b/tests/GosCompatTests/GosCompatSecureSpawnTests/Android.bp @@ -2,6 +2,8 @@ java_test_host { name: "GosCompatSecureSpawnTests", srcs: [ "src/**/*.java", + "GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/FailureCollector.java", + "GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/StatusComparison.java", ":GosPackageStateFlags", ], libs: [ diff --git a/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/AndroidManifest.xml b/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/AndroidManifest.xml index 6de7d2466b227..cfa064772c46e 100644 --- a/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/AndroidManifest.xml +++ b/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/AndroidManifest.xml @@ -5,7 +5,8 @@ + android:theme="@android:style/Theme.DeviceDefault.DayNight" + android:zygotePreloadNativeLib="libgoscompat_native_signal_probe.so"> + + + + + + + + + + #include #include -#include #include #include @@ -12,8 +11,8 @@ using aidl::app::grapheneos::goscompat::securespawn::BnNativeSignalProbe; class NativeSignalProbe final : public BnNativeSignalProbe { public: - ndk::ScopedAStatus terminateWithSigterm() override { - kill(getpid(), SIGTERM); + ndk::ScopedAStatus getPid(int32_t* _aidl_return) override { + *_aidl_return = static_cast(getpid()); return ndk::ScopedAStatus::ok(); } }; diff --git a/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/FailureCollector.java b/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/FailureCollector.java new file mode 100644 index 0000000000000..e1196e44cbff8 --- /dev/null +++ b/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/FailureCollector.java @@ -0,0 +1,198 @@ +package app.grapheneos.goscompat.securespawn; + +import org.junit.internal.AssumptionViolatedException; +import org.junit.rules.TestRule; +import org.junit.runner.Description; +import org.junit.runners.model.MultipleFailureException; +import org.junit.runners.model.Statement; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.ArrayList; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.Callable; + +/** Collects readable failures separately from their diagnostic exception traces. */ +public final class FailureCollector implements TestRule { + public static final String SUMMARY_KEY = "secureSpawnFailureSummary"; + public static final String DIAGNOSTICS_KEY = "secureSpawnFailureDiagnostics"; + + public interface Reporter { + void report(String summary, String diagnostics); + } + + private record Failure(String label, Throwable exception) {} + + private final List failures = new ArrayList<>(); + private final String reportName; + private final Reporter reporter; + + public FailureCollector() { + this(null, null); + } + + public FailureCollector(String reportName, Reporter reporter) { + this.reportName = reportName; + this.reporter = reporter; + } + + public T checkSucceeds(Callable check) { + return checkSucceeds(null, check); + } + + public T checkSucceeds(String label, Callable check) { + try { + return check.call(); + } catch (Throwable failure) { + addFailure(label, failure); + return null; + } + } + + void expectEquals(String label, String expected, String actual) { + if (!Objects.equals(expected, actual)) { + addFailure(label, new AssertionError(" expected: " + expected + "\n actual: " + actual)); + } + } + + private void addFailure(String label, Throwable failure) { + if (failure instanceof MultipleFailureException) { + for (Throwable child : ((MultipleFailureException) failure).getFailures()) { + addFailure(label, child); + } + } else { + failures.add(new Failure(label, failure)); + } + } + + /** Carries device details without embedding its serialized stack trace in another message. */ + public static AssertionError reportedFailure(String summary, String diagnostics) { + return new ReportedFailure(summary, diagnostics); + } + + private static final class ReportedFailure extends AssertionError { + final String diagnostics; + + ReportedFailure(String summary, String diagnostics) { + super(summary); + this.diagnostics = diagnostics; + setStackTrace(new StackTraceElement[0]); + } + } + + private static Set visited() { + return Collections.newSetFromMap(new IdentityHashMap<>()); + } + + private static void appendSummary(StringBuilder output, Throwable failure, String covered, + Set seen) { + if (!seen.add(failure)) { + return; + } + String message = failure instanceof AssertionError && failure.getMessage() != null + ? failure.getMessage() : failure.toString(); + // Wrappers often repeat their cause's message. Suppressed failures remain independent. + if (!covered.contains(message)) { + if (output.length() != 0) { + output.append('\n'); + } + output.append(message); + } + if (failure.getCause() != null) { + appendSummary(output, failure.getCause(), covered + "\n" + message, seen); + } + for (Throwable extra : failure.getSuppressed()) { + appendSummary(output, extra, "", seen); + } + } + + public static String diagnostics(Throwable failure) { + StringWriter output = new StringWriter(); + PrintWriter writer = new PrintWriter(output); + failure.printStackTrace(writer); + appendDeviceDiagnostics(writer, failure, visited()); + return output.toString(); + } + + private static void appendDeviceDiagnostics(PrintWriter output, Throwable failure, + Set seen) { + if (!seen.add(failure)) { + return; + } + if (failure instanceof ReportedFailure reported && reported.diagnostics != null) { + output.println("Device diagnostics:"); + output.println(reported.diagnostics); + } + if (failure.getCause() != null) { + appendDeviceDiagnostics(output, failure.getCause(), seen); + } + for (Throwable extra : failure.getSuppressed()) { + appendDeviceDiagnostics(output, extra, seen); + } + } + + @Override + public Statement apply(Statement base, Description description) { + return new Statement() { + @Override + public void evaluate() throws Throwable { + failures.clear(); + try { + base.evaluate(); + } catch (AssumptionViolatedException assumption) { + if (failures.isEmpty()) { + throw assumption; + } + addFailure(null, assumption); + } catch (Throwable failure) { + // Keep earlier comparisons even if the body or teardown subsequently throws. + addFailure(null, failure); + } + if (failures.isEmpty()) { + return; + } + StringBuilder summary = new StringBuilder(); + StringBuilder traces = new StringBuilder(); + for (int i = 0; i < failures.size(); ++i) { + Failure failure = failures.get(i); + String heading = (i + 1) + ") " + + (failure.label() == null ? "" : failure.label() + "\n"); + StringBuilder detail = new StringBuilder(); + appendSummary(detail, failure.exception(), "", visited()); + if (i != 0) { + summary.append("\n\n"); + } + summary.append(heading); + if (failure.label() != null && failure.exception() instanceof ReportedFailure) { + summary.append(" ").append(detail.toString().replace("\n", "\n ")); + } else { + summary.append(detail); + } + traces.append(heading).append(diagnostics(failure.exception())).append('\n'); + } + String message = failures.size() + " failure group(s)"; + if (reporter == null) { + message += "\n\n" + summary; + } else { + try { + reporter.report(summary.toString(), traces.toString()); + message += "; see " + reportName + "\n\n" + summary; + } catch (Throwable reportingFailure) { + // If publishing fails, retain all details in the ordinary JUnit failure. + throw new AssertionError(message + "\n\n" + summary + + "\n\nDiagnostics:\n" + traces, reportingFailure); + } + } + // JUnit splits MultipleFailureException into separate notifications. Use one + // ordinary AssertionError, without repeating the report as suppressed exceptions. + AssertionError combined = new AssertionError(message); + combined.setStackTrace(new StackTraceElement[0]); + throw combined; + } + }; + } +} diff --git a/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/ManagedAppZygoteProbeService.java b/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/ManagedAppZygoteProbeService.java new file mode 100644 index 0000000000000..f22c6e42b3c8f --- /dev/null +++ b/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/ManagedAppZygoteProbeService.java @@ -0,0 +1,4 @@ +package app.grapheneos.goscompat.securespawn; + +public final class ManagedAppZygoteProbeService extends ManagedProcessProbeService { +} diff --git a/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/ManagedIsolatedProbeService.java b/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/ManagedIsolatedProbeService.java new file mode 100644 index 0000000000000..2fbf89a986737 --- /dev/null +++ b/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/ManagedIsolatedProbeService.java @@ -0,0 +1,4 @@ +package app.grapheneos.goscompat.securespawn; + +public final class ManagedIsolatedProbeService extends ManagedProcessProbeService { +} diff --git a/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/ManagedProcessProbeService.java b/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/ManagedProcessProbeService.java new file mode 100644 index 0000000000000..49150d071e07f --- /dev/null +++ b/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/ManagedProcessProbeService.java @@ -0,0 +1,20 @@ +package app.grapheneos.goscompat.securespawn; + +import android.app.Service; +import android.content.Intent; +import android.os.IBinder; +import android.os.Process; + +public abstract class ManagedProcessProbeService extends Service { + private final IManagedProcessProbe.Stub mBinder = new IManagedProcessProbe.Stub() { + @Override + public int getPid() { + return Process.myPid(); + } + }; + + @Override + public IBinder onBind(Intent intent) { + return mBinder; + } +} diff --git a/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/ProcessStatus.java b/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/ProcessStatus.java new file mode 100644 index 0000000000000..5adf9e9086eef --- /dev/null +++ b/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/ProcessStatus.java @@ -0,0 +1,73 @@ +package app.grapheneos.goscompat.securespawn; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Consumer; + +/** One proc status snapshot, with independent assertions for each requested field. */ +final class ProcessStatus { + private final String path; + private final FailureCollector errors; + private final Map fields = new HashMap<>(); + + ProcessStatus(String path, String contents, FailureCollector errors) { + this.path = path; + this.errors = errors; + for (String line : contents.split("\\R")) { + int colon = line.indexOf(':'); + if (colon > 0) { + fields.put(line.substring(0, colon), line.substring(colon + 1).trim()); + } + } + } + + String value(String field) { + String value = fields.get(field); + if (value == null) { + throw new AssertionError(path + "\n" + field + ":\n expected: \n actual: "); + } + return value; + } + + void check(String field, Consumer assertion) { + errors.checkSucceeds(() -> { + try { + assertion.accept(value(field)); + } catch (AssertionError | RuntimeException e) { + throw new AssertionError(path + " field " + field + ": " + e.getMessage(), e); + } + return null; + }); + } + + void expect(String field, String expected) { + check(field, actual -> errors.expectEquals(path + "\n" + field + ":", expected, actual)); + } + + void expectIntegers(String field, boolean unordered, int... expected) { + check(field, value -> { + int[] actual = integers(value); + int[] wanted = expected.clone(); + if (unordered) { + Arrays.sort(actual); + Arrays.sort(wanted); + } + errors.expectEquals(path + "\n" + field + ":", Arrays.toString(wanted), + Arrays.toString(actual)); + }); + } + + void expectBits(String field, String description, long mask, long expected) { + check(field, value -> { + long actual = Long.parseUnsignedLong(value, 16) & mask; + errors.expectEquals(path + " (" + description + ")\n" + field + ":", + Long.toHexString(expected), Long.toHexString(actual)); + }); + } + + static int[] integers(String value) { + return value.isEmpty() ? new int[0] + : Arrays.stream(value.split("\\s+")).mapToInt(Integer::parseInt).toArray(); + } +} 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 index 06a552068618f..8509a7523e314 100644 --- a/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/SecureSpawnDeviceTest.java +++ b/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/SecureSpawnDeviceTest.java @@ -2,13 +2,20 @@ import static com.google.common.truth.Truth.assertWithMessage; +import android.app.Instrumentation; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.ServiceConnection; +import android.content.pm.PackageInfo; +import android.os.Bundle; import android.os.IBinder; +import android.os.ParcelFileDescriptor; import android.os.Process; +import android.system.OsConstants; import android.util.Log; +import android.webkit.WebView; +import android.webkit.WebViewClient; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.platform.app.InstrumentationRegistry; @@ -20,12 +27,18 @@ import app.grapheneos.goscompat.securespawn.shared.SecureSpawnSmapsCheck; import app.grapheneos.goscompat.securespawn.shared.SecureSpawnTestApiCompatCheck; +import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; @@ -34,7 +47,30 @@ public final class SecureSpawnDeviceTest { private static final String PACKAGE_NAME = "app.grapheneos.goscompat.securespawn"; private static final String TAG = "GosCompatSecureSpawn"; - private static final long NATIVE_SERVICE_TIMEOUT_SECONDS = 10; + private static final Set PRIMARY_ZYGOTE_NAMES = Set.of("zygote64", "zygote"); + // init starts the standalone native zygote as /system/bin/zygote_next with --name zygote_next. + private static final Set NATIVE_ZYGOTE_NAMES = + Set.of("zygote_next", "/system/bin/zygote_next"); + private static final long SERVICE_TIMEOUT_SECONDS = 10; + private static final long WEBVIEW_TIMEOUT_SECONDS = 5; + + @Rule + public final FailureCollector statusErrors = new FailureCollector( + "instrumentation failure results", (summary, diagnostics) -> { + Bundle results = new Bundle(); + results.putString(FailureCollector.SUMMARY_KEY, summary); + results.putString(FailureCollector.DIAGNOSTICS_KEY, diagnostics); + InstrumentationRegistry.getInstrumentation().addResults(results); + }); + + private interface WebViewRendererAction { + void run(WebViewRendererActivity activity, PackageInfo provider, int rendererPid) + throws Exception; + } + + private interface BoundServiceAction { + void run(IBinder binder) throws Exception; + } @Test public void execSpawned() { @@ -66,8 +102,212 @@ public void notExecSpawnedCompatZygote() { .that(result.hardenedMallocDisabled()).isTrue(); } + private enum ProcessKind { + APP, ISOLATED, NATIVE_ISOLATED, APP_ZYGOTE, NATIVE_APP_ZYGOTE, WEBVIEW + } + + @Test + public void appProcessStatus() throws Exception { + checkProcessStatus(ProcessKind.APP, Process.myPid(), Process.myUid()); + } + + @Test + public void isolatedServiceProcessStatus() throws Exception { + checkManagedServiceStatus(ProcessKind.ISOLATED, ".ManagedIsolatedProbeService"); + } + + @Test + public void appZygoteServiceProcessStatus() throws Exception { + checkManagedServiceStatus(ProcessKind.APP_ZYGOTE, ".ManagedAppZygoteProbeService"); + } + @Test - public void nativeServiceTerminatesOnSigterm() throws Exception { + public void nativeServiceProcessStatus() throws Exception { + checkNativeServiceStatus(ProcessKind.NATIVE_ISOLATED, ".NativeSignalProbeService"); + } + + @Test + public void nativeAppZygoteServiceProcessStatus() throws Exception { + checkNativeServiceStatus(ProcessKind.NATIVE_APP_ZYGOTE, + ".NativeAppZygoteSignalProbeService"); + } + + @Test + public void webViewRendererProcessStatus() throws Exception { + withWebViewRenderer((activity, provider, rendererPid) -> + checkProcessStatus(ProcessKind.WEBVIEW, rendererPid, provider.applicationInfo.uid)); + } + + private void checkManagedServiceStatus(ProcessKind kind, String service) throws Exception { + withBoundService(service, binder -> checkProcessStatus(kind, + IManagedProcessProbe.Stub.asInterface(binder).getPid(), Process.myUid())); + } + + private void checkNativeServiceStatus(ProcessKind kind, String service) throws Exception { + withBoundService(service, binder -> checkProcessStatus(kind, + INativeSignalProbe.Stub.asInterface(binder).getPid(), Process.myUid())); + } + + private void checkProcessStatus(ProcessKind kind, int pid, int definingUid) throws IOException { + String mode = InstrumentationRegistry.getArguments().getString("execSpawning"); + assertWithMessage("host must specify execSpawning").that(mode).isIn(Set.of("true", "false")); + boolean execSpawning = Boolean.parseBoolean(mode); + String path = "/proc/" + pid + "/status"; + String contents = shell("cat " + path); + // Publish the same snapshot before policy assertions, parent inspection or syscall probes. + // A normalization failure must not prevent the explicit field checks below. + statusErrors.checkSucceeds(() -> { + Bundle results = new Bundle(); + results.putString(StatusComparison.RESULT_KEY, + StatusComparison.snapshot(contents, kind != ProcessKind.APP)); + InstrumentationRegistry.getInstrumentation().addResults(results); + return null; + }); + ProcessStatus status = new ProcessStatus(path, contents, statusErrors); + if (kind == ProcessKind.APP) { + status.expectIntegers("Uid", false, definingUid, definingUid, definingUid, + definingUid); + status.expectIntegers("Gid", false, definingUid, definingUid, definingUid, + definingUid); + } + if (kind == ProcessKind.NATIVE_ISOLATED || kind == ProcessKind.NATIVE_APP_ZYGOTE) { + // Check the main thread's inherited mask directly instead of killing the service. + // Other threads may subsequently change their own signal masks. + long spawnSignals = signalBit(OsConstants.SIGCHLD) | signalBit(OsConstants.SIGINT) + | signalBit(OsConstants.SIGTERM); + status.expectBits("SigBlk", "SIGCHLD, SIGINT and SIGTERM bits", spawnSignals, 0); + status.expectBits("SigIgn", "SIGTERM bit", signalBit(OsConstants.SIGTERM), 0); + status.expectBits("SigCgt", "SIGTERM bit", signalBit(OsConstants.SIGTERM), 0); + } + checkParent(kind, status, execSpawning); + } + + private static long signalBit(int signal) { + return 1L << (signal - 1); + } + + private ProcessStatus readProcessStatus(int pid) throws IOException { + String path = "/proc/" + pid + "/status"; + return new ProcessStatus(path, shell("cat " + path), statusErrors); + } + + private ProcessStatus checkParent(ProcessKind kind, ProcessStatus status, + boolean execSpawning) { + Set expectedNames; + if (execSpawning || kind == ProcessKind.APP || kind == ProcessKind.ISOLATED) { + expectedNames = PRIMARY_ZYGOTE_NAMES; + } else { + expectedNames = switch (kind) { + case APP_ZYGOTE -> Set.of(PACKAGE_NAME + "_zygote"); + case NATIVE_APP_ZYGOTE -> Set.of(PACKAGE_NAME + "_zygote_native"); + case NATIVE_ISOLATED -> NATIVE_ZYGOTE_NAMES; + case WEBVIEW -> Set.of("webview_zygote"); + default -> throw new AssertionError(kind); + }; + } + // A malformed PPid prevents parent inspection, but must not suppress other child checks. + Integer parentPid = statusErrors.checkSucceeds(() -> + Integer.parseInt(status.value("PPid"))); + if (parentPid == null) { + return null; + } + statusErrors.checkSucceeds(() -> { + String actual = processName(parentPid); + if (!expectedNames.contains(actual)) { + statusErrors.expectEquals(kind + " parent (PID " + parentPid + ")", + "name: one of " + new java.util.TreeSet<>(expectedNames), "name: " + actual); + } + return null; + }); + return statusErrors.checkSucceeds(() -> readProcessStatus(parentPid)); + } + + private static void withWebViewRenderer(WebViewRendererAction action) throws Exception { + Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation(); + PackageInfo webViewPackage = WebView.getCurrentWebViewPackage(); + assertWithMessage("expected a current WebView provider") + .that(webViewPackage).isNotNull(); + + String providerPackageName = webViewPackage.packageName; + Map existingRenderers = webViewRendererProcesses(providerPackageName); + Intent intent = new Intent(instrumentation.getTargetContext(), + WebViewRendererActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + WebViewRendererActivity activity = + (WebViewRendererActivity) instrumentation.startActivitySync(intent); + try { + CountDownLatch pageLoaded = new CountDownLatch(1); + instrumentation.runOnMainSync(() -> { + WebView webView = activity.getWebView(); + webView.setWebViewClient(new WebViewClient() { + @Override + public void onPageFinished(WebView view, String url) { + pageLoaded.countDown(); + } + }); + webView.loadData("ready", "text/html", "UTF-8"); + }); + assertWithMessage("expected WebView page to finish loading") + .that(pageLoaded.await(WEBVIEW_TIMEOUT_SECONDS, TimeUnit.SECONDS)).isTrue(); + + Map renderers = webViewRendererProcesses(providerPackageName); + Set rendererPids = new HashSet<>(); + for (Map.Entry renderer : renderers.entrySet()) { + int parentPid = Integer.parseInt(renderer.getValue().split("\\s+")[1]); + // A process forked by a renderer keeps the renderer's name until it execs. + if (!existingRenderers.containsKey(renderer.getKey()) + && !renderers.containsKey(parentPid)) { + rendererPids.add(renderer.getKey()); + } + } + assertWithMessage("expected exactly one new renderer for " + providerPackageName + + "; renderers before: " + existingRenderers.values() + + "; renderers after: " + renderers.values()) + .that(rendererPids).hasSize(1); + int rendererPid = rendererPids.iterator().next(); + action.run(activity, webViewPackage, rendererPid); + } finally { + instrumentation.runOnMainSync(activity::finish); + instrumentation.waitForIdleSync(); + } + } + + /** Returns the ps line of each WebView renderer, keyed by PID. */ + private static Map webViewRendererProcesses(String providerPackageName) + throws IOException { + Map result = new HashMap<>(); + String processNamePrefix = providerPackageName + ":sandboxed_process"; + for (String line : shell("ps -A -o PID,PPID,UID,NAME:256").split("\\R")) { + String[] fields = line.trim().split("\\s+", 4); + if (fields.length == 4 && fields[3].startsWith(processNamePrefix)) { + result.put(Integer.parseInt(fields[0]), line.trim()); + } + } + return result; + } + + private static String processName(int pid) throws IOException { + String pidString = Integer.toString(pid); + for (String line : shell("ps -A -o PID,NAME:256").split("\\R")) { + String[] fields = line.trim().split("\\s+", 2); + if (fields.length == 2 && fields[0].equals(pidString)) { + return fields[1]; + } + } + throw new AssertionError("missing process " + pid); + } + + // UiAutomation runs commands as shell, which can read cross-UID proc paths on user builds. + private static String shell(String command) throws IOException { + ParcelFileDescriptor output = InstrumentationRegistry.getInstrumentation() + .getUiAutomation().executeShellCommand(command); + try (ParcelFileDescriptor.AutoCloseInputStream input = + new ParcelFileDescriptor.AutoCloseInputStream(output)) { + return new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + } + + private static void withBoundService( + String serviceClassName, BoundServiceAction action) throws Exception { Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); CompletableFuture connected = new CompletableFuture<>(); ServiceConnection connection = new ServiceConnection() { @@ -81,21 +321,15 @@ public void onServiceDisconnected(ComponentName name) {} }; boolean bound = context.bindService( new Intent().setComponent( - ComponentName.createRelative(PACKAGE_NAME, ".NativeSignalProbeService")), + ComponentName.createRelative(PACKAGE_NAME, serviceClassName)), connection, Context.BIND_AUTO_CREATE); try { - assertWithMessage("expected native signal probe service bind to succeed") + assertWithMessage("expected " + serviceClassName + " bind to succeed") .that(bound).isTrue(); - IBinder binder = connected.get(NATIVE_SERVICE_TIMEOUT_SECONDS, TimeUnit.SECONDS); - CountDownLatch terminated = new CountDownLatch(1); - binder.linkToDeath(terminated::countDown, 0); - INativeSignalProbe.Stub.asInterface(binder).terminateWithSigterm(); - - assertWithMessage("expected SIGTERM to terminate native signal probe service") - .that(terminated.await(NATIVE_SERVICE_TIMEOUT_SECONDS, TimeUnit.SECONDS)) - .isTrue(); + IBinder binder = connected.get(SERVICE_TIMEOUT_SECONDS, TimeUnit.SECONDS); + action.run(binder); } finally { if (bound) { context.unbindService(connection); diff --git a/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/StatusComparison.java b/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/StatusComparison.java new file mode 100644 index 0000000000000..76eb3cde997e5 --- /dev/null +++ b/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/StatusComparison.java @@ -0,0 +1,130 @@ +package app.grapheneos.goscompat.securespawn; + +import java.util.Arrays; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.concurrent.Callable; +import java.util.stream.Collectors; + +/** Compares stable proc status fields between conventional and exec spawning on the same device. */ +public final class StatusComparison { + public static final String RESULT_KEY = "stableProcessStatus"; + + // Identity and ancestry change with every launch; parent routing has separate policy tests. + private static final Set IDENTITY_FIELDS = Set.of( + "Name", "Tgid", "Ngid", "Pid", "PPid", "NStgid", "NSpid", "NSpgid", "NSsid"); + // Measurements and transient state depend on when the main thread is sampled. + private static final Set TRANSIENT_FIELDS = Set.of( + "State", "FDSize", "HugetlbPages", "Threads", "SigQ", "SigPnd", "ShdPnd", + "CoreDumping", "voluntary_ctxt_switches", "nonvoluntary_ctxt_switches"); + // Activity state and cpusets can change these independently of the spawning path. + private static final Set SCHEDULER_FIELDS = Set.of( + "Cpus_allowed", "Cpus_allowed_list", "Mems_allowed", "Mems_allowed_list"); + + private StatusComparison() {} + + /** Runs both captures and compares their snapshots even when policy checks have failed. */ + public static void checkBothModes(FailureCollector errors, Callable disabled, + Callable enabled) { + String before = errors.checkSucceeds("Exec spawning disabled: process capture", disabled); + String after = errors.checkSucceeds("Exec spawning enabled: process capture", enabled); + if (before != null && after != null) { + errors.checkSucceeds("Differences between exec spawning disabled and enabled", () -> { + String changes = diff(before, after); + if (!changes.isEmpty()) { + throw new AssertionError(changes); + } + return null; + }); + } + } + + public static String snapshot(String contents, boolean isolated) { + Map fields = parse(contents); + // Require credential fields so a failed proc read cannot become a passing empty snapshot. + long[] uids = credentials(fields, "Uid"); + long[] gids = credentials(fields, "Gid"); + if (!fields.containsKey("Groups")) { + throw new AssertionError("missing Groups in proc status"); + } + fields.keySet().removeIf(field -> IDENTITY_FIELDS.contains(field) + || TRANSIENT_FIELDS.contains(field) || SCHEDULER_FIELDS.contains(field) + || field.startsWith("Vm") || field.startsWith("Rss")); + // Android allocates isolated app IDs from 90000..99999, within each user's UID range. + // Leave unexpected IDs literal so normalization cannot hide a non-isolated credential. + boolean normalize = isolated && uids[0] % 100000 >= 90000 + && uids[0] % 100000 <= 99999; + fields.put("Uid", identity(uids, uids[0], normalize)); + fields.put("Gid", identity(gids, uids[0], normalize)); + String groups = fields.get("Groups"); + if (!groups.isEmpty()) { + fields.put("Groups", Arrays.stream(groups.split("\\s+")) + .mapToLong(Long::parseLong).sorted().mapToObj(Long::toString) + .collect(Collectors.joining(" "))); + } + StringBuilder result = new StringBuilder(); + fields.forEach((field, value) -> result.append(field).append(": ") + .append(value).append('\n')); + return result.toString(); + } + + /** Returns all differing fields, including fields present in only one snapshot. */ + public static String diff(String disabled, String enabled) { + Map before = parse(disabled); + Map after = parse(enabled); + Set fields = new TreeSet<>(before.keySet()); + fields.addAll(after.keySet()); + StringBuilder changes = new StringBuilder(); + for (String field : fields) { + if (!Objects.equals(before.get(field), after.get(field))) { + if (changes.length() != 0) { + changes.append('\n'); + } + changes.append(field).append(":\n exec spawning disabled: ") + .append(before.getOrDefault(field, "")).append('\n'); + changes.append(" exec spawning enabled: ") + .append(after.getOrDefault(field, "")).append('\n'); + } + } + return changes.toString(); + } + + private static Map parse(String contents) { + Map result = new TreeMap<>(); + for (String line : contents.split("\\R")) { + if (line.isBlank()) { + continue; + } + int colon = line.indexOf(':'); + if (colon <= 0) { + throw new AssertionError("malformed proc status field: " + line); + } + String field = line.substring(0, colon); + String value = line.substring(colon + 1).trim().replaceAll("\\s+", " "); + if (result.put(field, value) != null) { + throw new AssertionError("duplicate proc status field: " + field); + } + } + if (result.isEmpty()) { + throw new AssertionError("empty proc status snapshot"); + } + return result; + } + + private static long[] credentials(Map fields, String field) { + String value = fields.get(field); + if (value == null || value.split("\\s+").length != 4) { + throw new AssertionError("missing or malformed " + field + " in proc status"); + } + return Arrays.stream(value.split("\\s+")).mapToLong(Long::parseLong).toArray(); + } + + private static String identity(long[] values, long selfUid, boolean normalize) { + return Arrays.stream(values).mapToObj(value -> normalize && value == selfUid + ? "" : Long.toString(value)) + .collect(Collectors.joining(" ")); + } +} diff --git a/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/WebViewRendererActivity.java b/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/WebViewRendererActivity.java new file mode 100644 index 0000000000000..578a6b6d07204 --- /dev/null +++ b/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/WebViewRendererActivity.java @@ -0,0 +1,26 @@ +package app.grapheneos.goscompat.securespawn; + +import android.app.Activity; +import android.os.Bundle; +import android.webkit.WebView; + +public final class WebViewRendererActivity extends Activity { + private WebView mWebView; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + mWebView = new WebView(this); + setContentView(mWebView); + } + + WebView getWebView() { + return mWebView; + } + + @Override + protected void onDestroy() { + mWebView.destroy(); + super.onDestroy(); + } +} diff --git a/tests/GosCompatTests/GosCompatSecureSpawnTests/README.md b/tests/GosCompatTests/GosCompatSecureSpawnTests/README.md index 40909b3bdcf61..9d2d9e528c603 100644 --- a/tests/GosCompatTests/GosCompatSecureSpawnTests/README.md +++ b/tests/GosCompatTests/GosCompatSecureSpawnTests/README.md @@ -14,3 +14,53 @@ Run the module directly with: ```sh atest GosCompatSecureSpawnTests ``` + +The six tests in `SecureSpawnProcessStatusHostTest` each run both secure spawning modes: regular app, +managed isolated service, native isolated service, managed app zygote child, native app zygote +child, and WebView renderer. Each launch reads the child's `/proc//status` once through +UiAutomation as shell and reuses it for explicit assertions and the broad comparison; +`/proc/self/status` in a shell command would describe the shell utility instead of the target. + +Add assertions in `SecureSpawnDeviceTest#checkProcessStatus` with +`status.check("FieldName", value -> ...)`. The reader trims field values and preserves empty +fields such as `Groups:`. `FailureCollector` preserves every failed field assertion, including +missing fields, parsing failures and any later uncaught exception. Device instrumentation returns +separate summary and diagnostic results to the host. The host publishes failures from both modes +and the broad diff in the `secure_spawn_failures` test metric and attached text report. Original +exception traces are saved separately in `secure_spawn_failure_diagnostics`. Since atest only +prints test metrics for passing tests, the JUnit failure also contains the compact summary, without +exception traces. Reporting runs after teardown and before metrics are forwarded. A failure in +one mode does not prevent attempting the other. When a fresh snapshot is unavailable, the capture +failure is reported and the diff is skipped; previous instrumentation results are never reused. +Use `status.expect`, `status.expectIntegers` or `status.expectBits` for field comparisons: the +report shows only the compared values, labeled `expected` and `actual`. Signal values +are masked to the named bits. Entire proc status snapshots are not included. Parent inspection +failures are collected too. Service startup and renderer discovery remain prerequisites: there +is no process to inspect if those fail. + +The signal assertions inspect the main thread's mask and the process's signal dispositions. +They check inherited signal state directly, rather than testing delivery by terminating the +service. They do not assert that every thread retains the main thread's mask. + +The six `SecureSpawnProcessStatusHostTest` methods each capture the +same process type with exec spawning forced off and then on. They compare all status fields +except identities and ancestry, transient state, memory and thread measurements, pending signals, +context-switch counters, and scheduler-assigned CPU/NUMA masks. Unknown fields are compared by +default. The shared `StatusComparison` class lists the exclusions. + +The enabled and disabled host classes inherit their separate control cases from +`SecureSpawnModeHostTestBase`. The common fixture base has no test methods, so those cases do not +run a third time in the process status class. Package-state setup is shared and applies the +selected spawning mode in one command. + +Isolated UID/GID tuples are normalized against the process's real UID only when it belongs to the +isolated app ID range. The user ID and differences among real, effective, saved and filesystem +identities remain visible. Supplementary groups are sorted, not normalized away. Signal masks, +capability sets, seccomp mode and filter count, NoNewPrivs, umask and other stable fields are +compared in full. +Only differing fields appear in the comparison report, labeled `exec spawning disabled` and +`exec spawning enabled`. Expected-value checks have separate headings for each mode. The device publishes its snapshot before policy assertions, parent inspection and syscall +probes, so a later failure does not discard it or prevent the broad comparison. Snapshot +normalization failures are collected without stopping explicit assertions. Expected values and +syscall behavior are still checked independently: matching incorrect states in both modes fail +those checks even when the broad comparison produces no diff. 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 index e0d157ad0ba06..e2dbf206dbb9c 100644 --- a/tests/GosCompatTests/GosCompatSecureSpawnTests/src/app/grapheneos/goscompat/securespawn/tests/SecureSpawnDisabledHostTest.java +++ b/tests/GosCompatTests/GosCompatSecureSpawnTests/src/app/grapheneos/goscompat/securespawn/tests/SecureSpawnDisabledHostTest.java @@ -2,31 +2,16 @@ 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 { +public final class SecureSpawnDisabledHostTest extends SecureSpawnModeHostTestBase { - @Override - protected void resetPackageState() throws Exception { - editPackageState( - new int[] { - GosPackageStateFlag.USE_EXEC_SPAWNING_NON_DEFAULT, - }, - 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, - GosPackageStateFlag.USE_EXEC_SPAWNING, - }); + public SecureSpawnDisabledHostTest() { + super(false); } @Test 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 index 850e417bd3ea3..136084698ed96 100644 --- a/tests/GosCompatTests/GosCompatSecureSpawnTests/src/app/grapheneos/goscompat/securespawn/tests/SecureSpawnEnabledHostTest.java +++ b/tests/GosCompatTests/GosCompatSecureSpawnTests/src/app/grapheneos/goscompat/securespawn/tests/SecureSpawnEnabledHostTest.java @@ -1,32 +1,15 @@ 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 SecureSpawnEnabledHostTest extends SecureSpawnHostTestBase { +public final class SecureSpawnEnabledHostTest extends SecureSpawnModeHostTestBase { - @Override - protected void resetPackageState() throws Exception { - editPackageState( - new int[] { - GosPackageStateFlag.USE_EXEC_SPAWNING_NON_DEFAULT, - GosPackageStateFlag.USE_EXEC_SPAWNING, - }, - 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, - }); + public SecureSpawnEnabledHostTest() { + super(true); } @Test 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 index de66bdecce841..4281e526788a2 100644 --- a/tests/GosCompatTests/GosCompatSecureSpawnTests/src/app/grapheneos/goscompat/securespawn/tests/SecureSpawnHostTestBase.java +++ b/tests/GosCompatTests/GosCompatSecureSpawnTests/src/app/grapheneos/goscompat/securespawn/tests/SecureSpawnHostTestBase.java @@ -2,16 +2,18 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; -import static org.junit.Assume.assumeTrue; import android.content.pm.GosPackageStateFlag; +import app.grapheneos.goscompat.securespawn.FailureCollector; + 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.result.TestRunResult; import com.android.tradefed.testtype.DeviceJUnit4ClassRunner.TestLogData; +import com.android.tradefed.testtype.DeviceJUnit4ClassRunner.TestMetrics; import com.android.tradefed.testtype.junit4.BaseHostJUnit4Test; import com.android.tradefed.testtype.junit4.DeviceTestRunOptions; import com.android.tradefed.util.CommandResult; @@ -19,13 +21,14 @@ import org.junit.After; import org.junit.Before; import org.junit.Rule; -import org.junit.Test; import java.nio.charset.StandardCharsets; +import java.util.Arrays; +/** Shared device setup, instrumentation and reporting, without inherited test cases. */ abstract class SecureSpawnHostTestBase extends BaseHostJUnit4Test { - private static final String PACKAGE_NAME = "app.grapheneos.goscompat.securespawn"; - private static final String PROFILEABLE_PACKAGE_NAME = + protected static final String PACKAGE_NAME = "app.grapheneos.goscompat.securespawn"; + protected static final String PROFILEABLE_PACKAGE_NAME = "app.grapheneos.goscompat.securespawn.profileable"; private static final String TEST_CLASS = "app.grapheneos.goscompat.securespawn.SecureSpawnDeviceTest"; @@ -35,26 +38,55 @@ abstract class SecureSpawnHostTestBase extends BaseHostJUnit4Test { protected static final String MEMORY_ACCOUNTING_METHOD = "runtimeMemoryAccountingCheck"; protected static final String FD_STATE_METHOD = "fdStateCheck"; - private static final String PREV_SELINUX_CONTEXT_METHOD = "prevSELinuxContextCheck"; - private static final String HIDDEN_API_METHOD = + protected static final String PREV_SELINUX_CONTEXT_METHOD = "prevSELinuxContextCheck"; + protected static final String HIDDEN_API_METHOD = "hiddenApiEnforcementCheck"; - private static final String TEST_API_COMPAT_DEFAULT_METHOD = + protected static final String TEST_API_COMPAT_DEFAULT_METHOD = "testApiCompatDefaultCheck"; - private static final String TEST_API_COMPAT_DISABLED_METHOD = + protected static final String TEST_API_COMPAT_DISABLED_METHOD = "testApiCompatDisabledCheck"; - private static final String TEST_API_COMPAT_ENABLED_METHOD = + protected static final String TEST_API_COMPAT_ENABLED_METHOD = "testApiCompatEnabledCheck"; - private static final String PROFILEABLE_DUMPABLE_METHOD = + protected static final String PROFILEABLE_DUMPABLE_METHOD = "profileableFromShellDumpableCheck"; - private static final String ACYCLIC_REFLECTIVE_DUMP_METHOD = + protected static final String ACYCLIC_REFLECTIVE_DUMP_METHOD = "acyclicReflectiveDumpCheck"; + private static final String WEBVIEW_METHOD_PREFIX = "webViewRenderer"; + private static final String WEBVIEW_ACTIVITY_LIFECYCLE_TIMEOUT_MILLIS = "5000"; // 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 + protected final boolean defaultExecSpawning; + + protected SecureSpawnHostTestBase(boolean defaultExecSpawning) { + this.defaultExecSpawning = defaultExecSpawning; + } + + // The collector publishes after test teardown, before TestMetrics.after() forwards metrics. + @Rule(order = 0) + public final TestMetrics mMetrics = new TestMetrics(); + + @Rule(order = 1) public final TestLogData mLogs = new TestLogData(); + @Rule(order = 2) + public final FailureCollector statusErrors = new FailureCollector( + "secure_spawn_failures metric/report", this::reportFailures); + + private void reportFailures(String summary, String diagnostics) { + mMetrics.addTestMetric("secure_spawn_failures", "\n" + summary); + addFailureLog("secure_spawn_failures", summary); + addFailureLog("secure_spawn_failure_diagnostics", diagnostics); + } + + private void addFailureLog(String name, String text) { + try (ByteArrayInputStreamSource source = + new ByteArrayInputStreamSource(text.getBytes(StandardCharsets.UTF_8))) { + mLogs.addTestLog(name, LogDataType.TEXT, source); + } + } + @Before public void setUp() throws Exception { resetCompatState(); @@ -69,56 +101,20 @@ public void tearDown() throws Exception { shell("am force-stop " + PROFILEABLE_PACKAGE_NAME); } - @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); - } - - @Test - public void fdStateCheck() throws Exception { - runCheckCase(FD_STATE_METHOD); - } - - @Test - public void prevSELinuxContextCheck() throws Exception { - runCheckCase(PREV_SELINUX_CONTEXT_METHOD); + protected final void runDeviceTest(String methodName, boolean execSpawning) throws Exception { + runDeviceTest(PACKAGE_NAME, methodName, execSpawning); } - @Test - public void nativeServiceTerminatesOnSigterm() throws Exception { - runCheckCase("nativeServiceTerminatesOnSigterm"); + protected final void runDeviceTest(String methodName) throws Exception { + runDeviceTest(PACKAGE_NAME, methodName); } - protected void runDeviceTest(String methodName) throws Exception { - runDeviceTest(PACKAGE_NAME, methodName); + protected final void runDeviceTest(String packageName, String methodName) throws Exception { + runDeviceTest(packageName, methodName, defaultExecSpawning); } - private void runDeviceTest(String packageName, String methodName) throws Exception { + private void runDeviceTest(String packageName, String methodName, boolean execSpawning) + throws Exception { boolean collectResultLog = resultLogName(methodName) != null; boolean collectRootCauseLog = ACYCLIC_REFLECTIVE_DUMP_METHOD.equals(methodName); String originalRootCauseLogTag = null; @@ -135,22 +131,52 @@ private void runDeviceTest(String packageName, String methodName) throws Excepti DeviceTestRunOptions options = new DeviceTestRunOptions(packageName); options.setTestClassName(TEST_CLASS); options.setTestMethodName(methodName); + options.addInstrumentationArg("execSpawning", Boolean.toString(execSpawning)); + if (methodName.startsWith(WEBVIEW_METHOD_PREFIX)) { + options.addInstrumentationArg( + "activityLifecycleChangeTimeoutMillis", + WEBVIEW_ACTIVITY_LIFECYCLE_TIMEOUT_MILLIS); + } 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; + TestRunResult previous = getLastDeviceRunResults(); try { passed = runDeviceTests(options); } catch (AssertionError e) { - deviceAssertion = e; + TestRunResult result = getLastDeviceRunResults(); + String summary = result == null || result == previous ? null + : result.getRunMetrics().get(FailureCollector.SUMMARY_KEY); + if (summary != null && !summary.isBlank()) { + String diagnostics = + result.getRunMetrics().get(FailureCollector.DIAGNOSTICS_KEY); + if (result.isRunFailure()) { + summary += "\nInstrumentation run failure: " + + result.getRunFailureMessage(); + diagnostics = (diagnostics == null ? "" : diagnostics + "\n") + + FailureCollector.diagnostics(e); + } else if (diagnostics == null) { + diagnostics = FailureCollector.diagnostics(e); + } + deviceAssertion = FailureCollector.reportedFailure( + methodName + "\n" + summary, diagnostics); + } else { + // Crashes or failures outside the device rule may have no structured report. + // Keep the complete exception in the attached diagnostics, not in the summary. + deviceAssertion = FailureCollector.reportedFailure( + methodName + ": device test failed without a failure report; " + + "see secure_spawn_failure_diagnostics", + FailureCollector.diagnostics(e)); + } } catch (DeviceNotAvailableException e) { throw new IllegalStateException(e); } if (collectResultLog) { try { - logCheckResult(packageName, methodName); + logCheckResult(packageName, methodName, execSpawning); } catch (AssertionError | Exception e) { if (deviceAssertion == null) { throw e; @@ -170,12 +196,45 @@ private void runDeviceTest(String packageName, String methodName) throws Excepti } } - protected abstract void resetPackageState() throws Exception; + protected final void resetPackageState() throws Exception { + resetPackageState(PACKAGE_NAME, defaultExecSpawning); + } + + protected final void resetPackageState(String packageName) throws Exception { + resetPackageState(packageName, defaultExecSpawning); + } + + protected final void resetPackageState(boolean execSpawning) throws Exception { + resetPackageState(PACKAGE_NAME, execSpawning); + } + + private void resetPackageState(String packageName, boolean execSpawning) throws Exception { + int[] clearFlags = { + 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, + }; + int[] addFlags = execSpawning + ? new int[] { GosPackageStateFlag.USE_EXEC_SPAWNING_NON_DEFAULT, + GosPackageStateFlag.USE_EXEC_SPAWNING } + : new int[] { GosPackageStateFlag.USE_EXEC_SPAWNING_NON_DEFAULT }; + if (!execSpawning) { + clearFlags = Arrays.copyOf(clearFlags, clearFlags.length + 1); + clearFlags[clearFlags.length - 1] = GosPackageStateFlag.USE_EXEC_SPAWNING; + } + editPackageState(packageName, addFlags, clearFlags); + } + + protected final void editPackageState(int[] addFlags, int[] clearFlags) throws Exception { + editPackageState(PACKAGE_NAME, addFlags, clearFlags); + } - // TODO: Refactor this when HardeningTests get refactored - protected void editPackageState(int[] addFlags, int[] clearFlags) throws Exception { + private void editPackageState(String packageName, int[] addFlags, int[] clearFlags) + throws Exception { StringBuilder command = new StringBuilder("pm edit-gos-package-state ") - .append(PACKAGE_NAME) + .append(packageName) .append(' ') .append(getDevice().getCurrentUser()); for (int flag : addFlags) { @@ -188,48 +247,18 @@ protected void editPackageState(int[] addFlags, int[] clearFlags) throws Excepti shell(command.toString()); } - 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 { + protected final String shell(String command) throws Exception { return shell(getDevice(), command); } - private void logCheckResult(String packageName, String methodName) throws Exception { + private void logCheckResult(String packageName, String methodName, boolean execSpawning) + throws Exception { 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 = this instanceof SecureSpawnEnabledHostTest ? "enabled" : "disabled"; + String mode = execSpawning ? "enabled" : "disabled"; String logName = resultLogName(methodName); String report = "package=" + packageName + "\nmethod=" + methodName @@ -279,31 +308,16 @@ private static boolean isTestApiCompatMethod(String 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 { + protected final void resetCompatState() throws Exception { setCompatChange(CompatOverride.DEFAULT); } - private void setCompatChange(CompatOverride compatOverride) throws Exception { + protected final void setCompatChange(CompatOverride compatOverride) throws Exception { shell("am compat " + compatOverride.command() + " " + ALLOW_TEST_API_ACCESS_CHANGE_ID + " " + PACKAGE_NAME); } - private enum CompatOverride { + protected enum CompatOverride { DEFAULT("reset"), DISABLED("disable"), ENABLED("enable"); @@ -335,16 +349,4 @@ private static String shell(ITestDevice device, String command) throws Exception return result.getStdout() == null ? "" : result.getStdout(); } - 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; - } } diff --git a/tests/GosCompatTests/GosCompatSecureSpawnTests/src/app/grapheneos/goscompat/securespawn/tests/SecureSpawnModeHostTestBase.java b/tests/GosCompatTests/GosCompatSecureSpawnTests/src/app/grapheneos/goscompat/securespawn/tests/SecureSpawnModeHostTestBase.java new file mode 100644 index 0000000000000..da7228ca193b8 --- /dev/null +++ b/tests/GosCompatTests/GosCompatSecureSpawnTests/src/app/grapheneos/goscompat/securespawn/tests/SecureSpawnModeHostTestBase.java @@ -0,0 +1,96 @@ +package app.grapheneos.goscompat.securespawn.tests; + +import static org.junit.Assume.assumeTrue; + +import org.junit.Test; + +/** Cases run independently in the enabled and disabled control configurations. */ +abstract class SecureSpawnModeHostTestBase extends SecureSpawnHostTestBase { + + protected SecureSpawnModeHostTestBase(boolean execSpawning) { + super(execSpawning); + } + + @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); + } + + @Test + public void fdStateCheck() throws Exception { + runCheckCase(FD_STATE_METHOD); + } + + @Test + public void prevSELinuxContextCheck() throws Exception { + runCheckCase(PREV_SELINUX_CONTEXT_METHOD); + } + + 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(); + setCompatChange(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 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)); + } + +} diff --git a/tests/GosCompatTests/GosCompatSecureSpawnTests/src/app/grapheneos/goscompat/securespawn/tests/SecureSpawnProcessStatusHostTest.java b/tests/GosCompatTests/GosCompatSecureSpawnTests/src/app/grapheneos/goscompat/securespawn/tests/SecureSpawnProcessStatusHostTest.java new file mode 100644 index 0000000000000..4212de7696bef --- /dev/null +++ b/tests/GosCompatTests/GosCompatSecureSpawnTests/src/app/grapheneos/goscompat/securespawn/tests/SecureSpawnProcessStatusHostTest.java @@ -0,0 +1,75 @@ +package app.grapheneos.goscompat.securespawn.tests; + +import static org.junit.Assert.assertTrue; + +import app.grapheneos.goscompat.securespawn.StatusComparison; + +import com.android.tradefed.result.TestRunResult; +import com.android.tradefed.testtype.DeviceJUnit4ClassRunner; + +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Each case checks one process type in both spawning modes and compares the same snapshots. */ +@RunWith(DeviceJUnit4ClassRunner.class) +public final class SecureSpawnProcessStatusHostTest extends SecureSpawnHostTestBase { + public SecureSpawnProcessStatusHostTest() { + super(false); + } + + @Test + public void appProcessStatus() { + checkProcessStatus("appProcessStatus"); + } + + @Test + public void isolatedServiceProcessStatus() { + checkProcessStatus("isolatedServiceProcessStatus"); + } + + @Test + public void appZygoteServiceProcessStatus() { + checkProcessStatus("appZygoteServiceProcessStatus"); + } + + @Test + public void nativeServiceProcessStatus() { + checkProcessStatus("nativeServiceProcessStatus"); + } + + @Test + public void nativeAppZygoteServiceProcessStatus() { + checkProcessStatus("nativeAppZygoteServiceProcessStatus"); + } + + @Test + public void webViewRendererProcessStatus() { + checkProcessStatus("webViewRendererProcessStatus"); + } + + private void checkProcessStatus(String methodName) { + StatusComparison.checkBothModes(statusErrors, + () -> captureStatusForDiff(methodName, false), + () -> captureStatusForDiff(methodName, true)); + } + + private String captureStatusForDiff(String methodName, boolean execSpawning) + throws Exception { + resetPackageState(execSpawning); + // A failed device assertion still leaves run metrics containing the pre-probe snapshot. + // Keep it for the diff, but never reuse a previous run's metrics if instrumentation fails. + TestRunResult previous = getLastDeviceRunResults(); + String mode = execSpawning ? "Exec spawning enabled" : "Exec spawning disabled"; + statusErrors.checkSucceeds(mode + ": checks against expected values", () -> { + runDeviceTest(methodName, execSpawning); + return null; + }); + TestRunResult result = getLastDeviceRunResults(); + assertTrue("missing fresh instrumentation result from " + methodName, + result != null && result != previous); + String snapshot = result.getRunMetrics().get(StatusComparison.RESULT_KEY); + assertTrue("missing stable status result from " + methodName, + snapshot != null && !snapshot.isBlank()); + return snapshot; + } +} From da4a521384845d348e2b5f7bb8f234586673510b Mon Sep 17 00:00:00 2001 From: inthewaves Date: Sat, 31 Oct 2026 18:39:59 -0700 Subject: [PATCH 2/5] exec spawning: restore secondary zygote supplementary groups Conventional Java app and WebView zygote children inherit the defining package's shared app GID from their parent. Exec-spawned replacements start from the primary zygote instead and were retaining its readproc and reserved_disk groups. Mark app zygote replacements and request the defining package's shared app GID when app or WebView zygote semantics are required and the caller did not provide an explicit group list. Preserve nonempty group lists unchanged. NativeZygoteProcess does not pass groups to native spawn requests, including the shared app GID that AppZygote requests for a native app zygote. Native app zygote replacements therefore keep matching conventional native app zygote children, which have no supplementary groups. Test: atest GosCompatSecureSpawnTests --- .../java/com/android/server/am/ActiveServices.java | 1 + .../java/com/android/server/am/HostingRecord.java | 11 +++++++++++ .../core/java/com/android/server/am/ProcessList.java | 9 +++++++++ 3 files changed, 21 insertions(+) diff --git a/services/core/java/com/android/server/am/ActiveServices.java b/services/core/java/com/android/server/am/ActiveServices.java index 39297c619a048..b13c10226f7d8 100644 --- a/services/core/java/com/android/server/am/ActiveServices.java +++ b/services/core/java/com/android/server/am/ActiveServices.java @@ -6348,6 +6348,7 @@ private String bringUpServiceInnerLocked(ServiceRecord r, int intentFlags, boole if (AswUseExecSpawning.isEnabledFor(mAm.mContext, userId, definingAppInfo, GosPackageState.get(definingPkgName, userId), isNativeService)) { + hostingRecord.setAppZygoteReplacement(); boolean preload = (isNativeService ? definingAppInfo.zygotePreloadNativeLib : definingAppInfo.zygotePreloadName) != null; diff --git a/services/core/java/com/android/server/am/HostingRecord.java b/services/core/java/com/android/server/am/HostingRecord.java index 9b60b9c0bf7af..ca465b3c9bfcd 100644 --- a/services/core/java/com/android/server/am/HostingRecord.java +++ b/services/core/java/com/android/server/am/HostingRecord.java @@ -435,6 +435,17 @@ public ApplicationInfo getAppInfoForPreloading() { return mAppInfoForPreloading; } + // Whether exec spawning replaces an app zygote requested for this process. + private boolean mIsAppZygoteReplacement; + + void setAppZygoteReplacement() { + mIsAppZygoteReplacement = true; + } + + boolean isAppZygoteReplacement() { + return mIsAppZygoteReplacement; + } + /** * @return if the process should be spawned from the Native App Zygote */ diff --git a/services/core/java/com/android/server/am/ProcessList.java b/services/core/java/com/android/server/am/ProcessList.java index 89e9680478120..47ee196e17569 100644 --- a/services/core/java/com/android/server/am/ProcessList.java +++ b/services/core/java/com/android/server/am/ProcessList.java @@ -2119,6 +2119,15 @@ boolean startProcessLocked(ProcessRecord app, HostingRecord hostingRecord, } } } + // App and WebView zygote children inherit the defining package's shared app GID. + // Exec-spawned replacements start from the primary zygote, so request it explicitly. + if (zygoteExtArgs.shouldUseExecSpawning() && ArrayUtils.isEmpty(gids) + && (hostingRecord.usesWebviewZygote_() + || hostingRecord.isAppZygoteReplacement())) { + gids = new int[] { UserHandle.getSharedAppGid( + UserHandle.getAppId(hostingRecord.getDefiningUid())) }; + app.setGids(gids); + } // the per-user SELinux context must be set if (TextUtils.isEmpty(app.info.seInfoUser)) { From 688e073dcaabebd0bcf261f695309642060900e6 Mon Sep 17 00:00:00 2001 From: inthewaves Date: Fri, 17 Jul 2026 17:12:07 -0700 Subject: [PATCH 3/5] SecureSpawnTests: check supplementary groups in process status tests Check Groups for all six process types and their spawn parents in the existing status tests. Read native service groups from proc status too, avoiding a separate getgroups Binder method. Compare numeric group lists without depending on ordering or whitespace. Expect the package shared app GID for managed app zygote and WebView children, no groups for native services, primary zygote groups for managed isolated services, and the shared app, cache app and user GIDs for regular apps. Preserve the distinct parent expectations in each spawning mode. Test: atest GosCompatSecureSpawnTests --- .../securespawn/SecureSpawnDeviceTest.java | 41 ++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) 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 index 8509a7523e314..0ca4100e5eed3 100644 --- a/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/SecureSpawnDeviceTest.java +++ b/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/SecureSpawnDeviceTest.java @@ -47,6 +47,14 @@ public final class SecureSpawnDeviceTest { private static final String PACKAGE_NAME = "app.grapheneos.goscompat.securespawn"; private static final String TAG = "GosCompatSecureSpawn"; + // Mirrors UserHandle shared app, cache app and user GID calculations. + private static final int PER_USER_RANGE = 100000; + private static final int FIRST_SHARED_APP_GID = 50000; + private static final int FIRST_CACHE_APP_GID = 20000; + private static final int SHARED_USER_GID = 9997; + // Primary zygote groups from system/core/rootdir/init.zygote64.rc. + private static final int AID_RESERVED_DISK = 1065; + private static final int AID_READPROC = 3009; private static final Set PRIMARY_ZYGOTE_NAMES = Set.of("zygote64", "zygote"); // init starts the standalone native zygote as /system/bin/zygote_next with --name zygote_next. private static final Set NATIVE_ZYGOTE_NAMES = @@ -179,7 +187,38 @@ private void checkProcessStatus(ProcessKind kind, int pid, int definingUid) thro status.expectBits("SigIgn", "SIGTERM bit", signalBit(OsConstants.SIGTERM), 0); status.expectBits("SigCgt", "SIGTERM bit", signalBit(OsConstants.SIGTERM), 0); } - checkParent(kind, status, execSpawning); + int appId = definingUid % PER_USER_RANGE; + int sharedAppGid = FIRST_SHARED_APP_GID + appId - Process.FIRST_APPLICATION_UID; + switch (kind) { + case APP -> { + // ProcessList.computeGidsForProcess(), without permission or storage GIDs. + int userId = definingUid / PER_USER_RANGE; + int cacheAppGid = userId * PER_USER_RANGE + FIRST_CACHE_APP_GID + + appId - Process.FIRST_APPLICATION_UID; + int userGid = userId * PER_USER_RANGE + SHARED_USER_GID; + checkGroups(status, sharedAppGid, cacheAppGid, userGid); + } + // Isolated managed processes keep the primary zygote's groups when no list is passed. + case ISOLATED -> checkGroups(status, AID_RESERVED_DISK, AID_READPROC); + case APP_ZYGOTE, WEBVIEW -> checkGroups(status, sharedAppGid); + // NativeZygoteProcess does not pass groups, even the shared app GID requested by + // AppZygote. Both native service paths therefore have an empty group list. + case NATIVE_ISOLATED, NATIVE_APP_ZYGOTE -> checkGroups(status); + } + ProcessStatus parent = checkParent(kind, status, execSpawning); + if (parent != null) { + if (execSpawning || kind == ProcessKind.APP || kind == ProcessKind.ISOLATED) { + checkGroups(parent, AID_RESERVED_DISK, AID_READPROC); + } else if (kind == ProcessKind.APP_ZYGOTE || kind == ProcessKind.WEBVIEW) { + checkGroups(parent, sharedAppGid); + } else { + checkGroups(parent); + } + } + } + + private static void checkGroups(ProcessStatus status, int... expected) { + status.expectIntegers("Groups", true, expected); } private static long signalBit(int signal) { From ac226b7e0254f601472cf38300e7ac564bde9c38 Mon Sep 17 00:00:00 2001 From: inthewaves Date: Mon, 2 Nov 2026 04:48:29 -0800 Subject: [PATCH 4/5] exec spawning: set NO_NEW_PRIVS for app zygote and WebView zygote replacements App and WebView zygote children inherit NO_NEW_PRIVS from their parent. Exec-spawned replacements start from the primary zygote instead, so request it explicitly. Requires the system/zygote change that adds no_new_privs to SpawnCommon. --- core/java/android/os/NativeZygoteProcess.java | 5 +++-- .../com/android/internal/os/ZygoteExtraArgs.java | 4 ++++ core/jni/android_os_NativeZygoteProcess.cpp | 12 ++++++------ core/jni/com_android_internal_os_Zygote.cpp | 7 +++++++ .../core/java/com/android/server/am/ProcessList.java | 6 ++++++ 5 files changed, 26 insertions(+), 8 deletions(-) diff --git a/core/java/android/os/NativeZygoteProcess.java b/core/java/android/os/NativeZygoteProcess.java index 5d3bd79493dec..d88a3ab7747c5 100644 --- a/core/java/android/os/NativeZygoteProcess.java +++ b/core/java/android/os/NativeZygoteProcess.java @@ -87,7 +87,7 @@ private synchronized void connectToZygote() throws IOException { } private static native int nativeStartNativeProcess(byte[][] outCapturedCommand, long selinuxFlags, - byte[] preloadCommand, + boolean noNewPrivs, byte[] preloadCommand, FileDescriptor fd, int uid, int gid, long startSeq, String packageName, String niceName, int targetSdkVersion, boolean startChildZygote, int runtimeFlags, String seInfo, boolean isTopApp) @@ -164,7 +164,8 @@ public final Process.ProcessStartResult start(@NonNull final ZygoteExtraArgs zyg outCapturedCommand = null; preloadCommand = null; } - int res = nativeStartNativeProcess(outCapturedCommand, zygoteExtArgs.getSelinuxFlags(), preloadCommand, fd, uid, gid, startSeq, + int res = nativeStartNativeProcess(outCapturedCommand, zygoteExtArgs.getSelinuxFlags(), + zygoteExtArgs.hasFlag(ZygoteExtraArgs.Flag.SET_NO_NEW_PRIVS), preloadCommand, fd, uid, gid, startSeq, packageName, niceName, targetSdkVersion, /*startChildZygote=*/false, runtimeFlags, seInfo, isTopApp); if (!useExecSpawning) { diff --git a/core/java/com/android/internal/os/ZygoteExtraArgs.java b/core/java/com/android/internal/os/ZygoteExtraArgs.java index 50b556f339bec..bdcf404b2f883 100644 --- a/core/java/com/android/internal/os/ZygoteExtraArgs.java +++ b/core/java/com/android/internal/os/ZygoteExtraArgs.java @@ -43,6 +43,9 @@ public interface Flag { int USE_ZYGOTE_SPAWNING = 1 << 3; int PREFER_COMPAT_ZYGOTE = 1 << 4; int MANUALLY_RUN_ZYGOTE_PRELOAD = 1 << 5; + // set for exec-spawned replacements of app zygote and WebView zygote children, which don't + // inherit NO_NEW_PRIVS from a child zygote + int SET_NO_NEW_PRIVS = 1 << 6; @IntDef(flag = true, value = { DISABLE_HARDENED_MALLOC, @@ -50,6 +53,7 @@ public interface Flag { FORCIBLY_ENABLE_MEMORY_TAGGING, PREFER_COMPAT_ZYGOTE, MANUALLY_RUN_ZYGOTE_PRELOAD, + SET_NO_NEW_PRIVS, }) @Retention(RetentionPolicy.SOURCE) @interface Enum {} diff --git a/core/jni/android_os_NativeZygoteProcess.cpp b/core/jni/android_os_NativeZygoteProcess.cpp index 0c53c5a7713d6..4eedf779e5fee 100644 --- a/core/jni/android_os_NativeZygoteProcess.cpp +++ b/core/jni/android_os_NativeZygoteProcess.cpp @@ -59,7 +59,7 @@ static std::optional extract_jstring(JNIEnv* env, jstring manage return ScopedUtfChars(env, managed_string); } -static void CreateSpawnParcel(flatbuffers::FlatBufferBuilder& builder, JNIEnv* env, jlong selinux_flags, jint uid, +static void CreateSpawnParcel(flatbuffers::FlatBufferBuilder& builder, JNIEnv* env, jlong selinux_flags, bool no_new_privs, jint uid, jint gid, const char* niceNameStr, bool is_child_zygote, const char* seInfoStr, const char* socketPathStr, SpawnPayload payload_type, flatbuffers::Offset payload) { @@ -77,7 +77,7 @@ static void CreateSpawnParcel(flatbuffers::FlatBufferBuilder& builder, JNIEnv* e std::vector secondary_groups; std::vector rlimits; auto spawnCommon = - CreateSpawnCommonDirect(builder, selinux_flags, uid, gid, niceNameStr, priority_initial, + CreateSpawnCommonDirect(builder, selinux_flags, no_new_privs, uid, gid, niceNameStr, priority_initial, priority_final, cap_effective, cap_permitted, cap_inheritable, cap_bound, seInfoStr, &secondary_groups, &rlimits); flatbuffers::Offset spawnUnion; @@ -153,7 +153,7 @@ static jint captureCommand(JNIEnv* env, uint8_t* buf, ssize_t size, jobjectArray static jint android_os_NativeZygoteProcess_startNativeProcess( JNIEnv* env, jclass /* classObj */, - jobjectArray outCapturedCommand, jlong selinuxFlags, jbyteArray preloadCommand, + jobjectArray outCapturedCommand, jlong selinuxFlags, jboolean noNewPrivs, jbyteArray preloadCommand, jobject sockFd, jint uid, jint gid, jlong startSeq, jstring packageName, jstring niceName, jint targetSdkVersion, jboolean startChildZygote, jint runtimeFlags, jstring seInfo, jboolean isTopApp) { @@ -175,7 +175,7 @@ static jint android_os_NativeZygoteProcess_startNativeProcess( CreateSpawnAndroidNativeDirect(builder, &preloadCommandVec, packageNamePtr, startSeq, targetSdkVersion, static_cast(runtimeFlags), is_top_app); bool is_child_zygote = startChildZygote == JNI_TRUE; - CreateSpawnParcel(builder, env, selinuxFlags, uid, gid, niceNamePtr, is_child_zygote, seInfoPtr, + CreateSpawnParcel(builder, env, selinuxFlags, noNewPrivs == JNI_TRUE, uid, gid, niceNamePtr, is_child_zygote, seInfoPtr, /**subspecies_data=*/nullptr, SpawnPayload_SpawnAndroidNative, spawnAndroidNativeCmd.Union()); uint8_t* buf = builder.GetBufferPointer(); @@ -241,7 +241,7 @@ static jint android_os_NativeZygoteProcess_startNativeChildZygote( return captureCommand(env, builder.GetBufferPointer(), builder.GetSize(), outCapturedCommand); } - CreateSpawnParcel(builder, env, selinuxFlags, uid, gid, niceNameStr, /**is_child_zygote=*/true, seInfoStr, + CreateSpawnParcel(builder, env, selinuxFlags, /**no_new_privs=*/false, uid, gid, niceNameStr, /**is_child_zygote=*/true, seInfoStr, serverPathStr, SpawnPayload_SpawnSubspeciesAndroidNative, spawnAndroidChildZygoteCmd.Union()); @@ -293,7 +293,7 @@ static void android_os_NativeZygoteProcess_prewarmNativeZygote(JNIEnv* env, jcla static const JNINativeMethod method_table[] = { /* name, signature, funcPtr */ {"nativeStartNativeProcess", - "([[BJ[BLjava/io/FileDescriptor;IIJLjava/lang/String;Ljava/lang/String;IZILjava/lang/String;Z)I", + "([[BJZ[BLjava/io/FileDescriptor;IIJLjava/lang/String;Ljava/lang/String;IZILjava/lang/String;Z)I", (void*)android_os_NativeZygoteProcess_startNativeProcess}, {"nativeStartNativeChildZygote", "([[BJLjava/io/FileDescriptor;IILjava/lang/String;Ljava/lang/String;II" diff --git a/core/jni/com_android_internal_os_Zygote.cpp b/core/jni/com_android_internal_os_Zygote.cpp index 05bdd2910bd33..50e614d595e53 100644 --- a/core/jni/com_android_internal_os_Zygote.cpp +++ b/core/jni/com_android_internal_os_Zygote.cpp @@ -366,6 +366,7 @@ enum RuntimeFlags : uint32_t { namespace ExtraArgsFlag { static const int FORCIBLY_ENABLE_MEMORY_TAGGING = 1 << 2; + static const int SET_NO_NEW_PRIVS = 1 << 6; } struct ExtraArgs { @@ -2113,6 +2114,12 @@ static void SpecializeCommon(JNIEnv* env, ExtraArgs& extra_args, uid_t uid, gid_ is_system_server, se_info_ptr, nice_name_ptr, extra_args.selinux_flags)); } + // set after the SELinux context transition, which NO_NEW_PRIVS would otherwise restrict + if (extra_args.hasFlag(ExtraArgsFlag::SET_NO_NEW_PRIVS) && + prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) == -1) { + fail_fn(CREATE_ERROR("prctl(PR_SET_NO_NEW_PRIVS) failed: %s", strerror(errno))); + } + if ((runtime_flags & RuntimeFlags::AUDIT_OUTGOING_TRANSACTIONS) != 0) { android::ProcessState::self()->setIsOutgoingTransactionsAuditable(true); // Now that we've used the flag, clear it so that we don't pass unknown flags to the ART diff --git a/services/core/java/com/android/server/am/ProcessList.java b/services/core/java/com/android/server/am/ProcessList.java index 47ee196e17569..b6bc4c3e0d217 100644 --- a/services/core/java/com/android/server/am/ProcessList.java +++ b/services/core/java/com/android/server/am/ProcessList.java @@ -2128,6 +2128,12 @@ boolean startProcessLocked(ProcessRecord app, HostingRecord hostingRecord, UserHandle.getAppId(hostingRecord.getDefiningUid())) }; app.setGids(gids); } + // App and WebView zygote children inherit NO_NEW_PRIVS from their parent. + // Exec-spawned replacements start from the primary zygote, so request it explicitly. + if (zygoteExtArgs.shouldUseExecSpawning() && (hostingRecord.usesWebviewZygote_() + || hostingRecord.isAppZygoteReplacement())) { + zygoteExtArgs.setFlag(ZygoteExtraArgs.Flag.SET_NO_NEW_PRIVS, true); + } // the per-user SELinux context must be set if (TextUtils.isEmpty(app.info.seInfoUser)) { From 88ada8fe90e03b5306c8eebac51618133453d1f2 Mon Sep 17 00:00:00 2001 From: inthewaves Date: Mon, 2 Nov 2026 04:49:16 -0800 Subject: [PATCH 5/5] SecureSpawnTests: check NoNewPrivs in process status tests Require managed and native app zygote children and WebView renderers to have NoNewPrivs set in both spawning modes. Regular apps and isolated services without an app zygote must have it clear. Add these assertions to the existing snapshots without another process launch or proc read. Chromium also sets NO_NEW_PRIVS in its renderer sandbox, so the WebView assertion records the required final state without identifying which layer set the bit. Test: atest GosCompatSecureSpawnTests --- .../goscompat/securespawn/SecureSpawnDeviceTest.java | 7 +++++++ 1 file changed, 7 insertions(+) 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 index 0ca4100e5eed3..a68c5470e1abc 100644 --- a/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/SecureSpawnDeviceTest.java +++ b/tests/GosCompatTests/GosCompatSecureSpawnTests/GosCompatSecureSpawnApp/src/app/grapheneos/goscompat/securespawn/SecureSpawnDeviceTest.java @@ -187,6 +187,13 @@ private void checkProcessStatus(ProcessKind kind, int pid, int definingUid) thro status.expectBits("SigIgn", "SIGTERM bit", signalBit(OsConstants.SIGTERM), 0); status.expectBits("SigCgt", "SIGTERM bit", signalBit(OsConstants.SIGTERM), 0); } + String expectedNoNewPrivs = switch (kind) { + case APP_ZYGOTE, NATIVE_APP_ZYGOTE, WEBVIEW -> "1"; + default -> "0"; + }; + // Chromium also sets this bit in its renderer sandbox, so the WebView assertion checks + // the final policy rather than identifying which layer set it. + status.expect("NoNewPrivs", expectedNoNewPrivs); int appId = definingUid % PER_USER_RANGE; int sharedAppGid = FIRST_SHARED_APP_GID + appId - Process.FIRST_APPLICATION_UID; switch (kind) {