/* * Copyright 2017 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.server.pm.dex; import static com.google.common.truth.Truth.assertThat; import static com.google.common.truth.Truth.assertWithMessage; import static org.junit.Assume.assumeFalse; import android.app.UiAutomation; import android.content.Context; import android.content.pm.PackageManager; import android.os.Build; import android.os.ParcelFileDescriptor; import android.os.SystemClock; import android.util.EventLog; import android.util.EventLog.Event; import androidx.test.filters.LargeTest; import androidx.test.platform.app.InstrumentationRegistry; import dalvik.system.DexClassLoader; import org.junit.Before; import org.junit.BeforeClass; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.JUnit4; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileOutputStream; import java.io.InputStream; import java.io.OutputStream; import java.security.MessageDigest; import java.util.ArrayList; import java.util.Formatter; import java.util.List; import java.util.concurrent.TimeUnit; /** * Integration tests for {@link DynamicCodeLogger}. * * The setup for the test dynamically loads code in a jar extracted * from our assets (a secondary dex file). * * We then use shell commands to trigger dynamic code logging (and wait * for it to complete). This causes DynamicCodeLogger to log the hash of the * file's name and content. We verify that this message appears in * the event log. * * Run with "atest DynamicCodeLoggerIntegrationTests". */ @LargeTest @RunWith(JUnit4.class) public final class DynamicCodeLoggerIntegrationTests { private static final String SHA_256 = "SHA-256"; // Event log tag used for SNET related events private static final int SNET_TAG = 0x534e4554; // Subtags used to distinguish dynamic code loading events private static final String DCL_DEX_SUBTAG = "dcl"; private static final String DCL_NATIVE_SUBTAG = "dcln"; // These are job IDs from DynamicCodeLoggingService private static final int IDLE_LOGGING_JOB_ID = 2030028; private static final int AUDIT_WATCHING_JOB_ID = 203142925; // For tests that rely on parsing audit logs, how often to retry. (There are many reasons why // we might not see the audit logs, including throttling and delays in log generation, so to // avoid flakiness we run these tests multiple times, allowing progressively longer between // code loading and checking the logs on each try.) private static final int AUDIT_LOG_RETRIES = 10; private static final int RETRY_DELAY_MS = 500; private static Context sContext; private static int sMyUid; @BeforeClass public static void setUpAll() { sContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); sMyUid = android.os.Process.myUid(); } @Before public void setup() { assumeFalse(sContext.getPackageManager().hasSystemFeature(PackageManager.FEATURE_WATCH)); primeEventLog(); } private void primeEventLog() { // Force a round trip to logd to make sure everything is up to date. // Without this the first test passes and others don't - we don't see new events in the // log. The exact reason is unclear. EventLog.writeEvent(SNET_TAG, "Dummy event"); // Audit log messages are throttled by the kernel (at the request of logd) to 5 per // second, so running the tests too quickly in sequence means we lose some and get // spurious failures. Sigh. SystemClock.sleep(1000); } @Test public void testGeneratesEvents_standardClassLoader() throws Exception { File privateCopyFile = privateFile("copied.jar"); // Obtained via "echo -n copied.jar | sha256sum" String expectedNameHash = "1B6C71DB26F36582867432CCA12FB6A517470C9F9AABE9198DD4C5C030D6DC0C"; String expectedContentHash = copyAndHashResource( "/DynamicCodeLoggerTestLibrary.jar", privateCopyFile); // Feed the jar to a class loader and make sure it contains what we expect. ClassLoader parentClassLoader = sContext.getClass().getClassLoader(); ClassLoader loader = new DexClassLoader(privateCopyFile.toString(), null, null, parentClassLoader); loader.loadClass("com.android.dcl.Simple"); // And make sure we log events about it long previousEventNanos = mostRecentEventTimeNanos(); runDynamicCodeLoggingJob(IDLE_LOGGING_JOB_ID); assertDclLoggedSince(previousEventNanos, DCL_DEX_SUBTAG, expectedNameHash, expectedContentHash); } @Test public void testGeneratesEvents_unknownClassLoader() throws Exception { File privateCopyFile = privateFile("copied2.jar"); String expectedNameHash = "202158B6A3169D78F1722487205A6B036B3F2F5653FDCFB4E74710611AC7EB93"; String expectedContentHash = copyAndHashResource( "/DynamicCodeLoggerTestLibrary.jar", privateCopyFile); // This time make sure an unknown class loader is an ancestor of the class loader we use. ClassLoader knownClassLoader = sContext.getClass().getClassLoader(); ClassLoader unknownClassLoader = new UnknownClassLoader(knownClassLoader); ClassLoader loader = new DexClassLoader(privateCopyFile.toString(), null, null, unknownClassLoader); loader.loadClass("com.android.dcl.Simple"); // And make sure we log events about it long previousEventNanos = mostRecentEventTimeNanos(); runDynamicCodeLoggingJob(IDLE_LOGGING_JOB_ID); assertDclLoggedSince(previousEventNanos, DCL_DEX_SUBTAG, expectedNameHash, expectedContentHash); } @Test public void testGeneratesEvents_nativeLibrary() throws Exception { new TestNativeCodeWithRetries() { @Override protected void loadNativeCode(int tryNumber) throws Exception { // We need to use a different file name for each retry, because once a file is // loaded, re-loading it has no effect. String privateCopyName = "copied" + tryNumber + ".so"; File privateCopyFile = privateFile(privateCopyName); mExpectedNameHash = hashOf(privateCopyName); mExpectedContentHash = copyAndHashResource( libraryPath("DynamicCodeLoggerNativeTestLibrary.so"), privateCopyFile); System.load(privateCopyFile.toString()); } }.runTest(); } @Test public void testGeneratesEvents_nativeLibrary_escapedName() throws Exception { new TestNativeCodeWithRetries() { @Override protected void loadNativeCode(int tryNumber) throws Exception { // A file name with a space will be escaped in the audit log; verify we un-escape it // correctly. String privateCopyName = "second copy " + tryNumber + ".so"; File privateCopyFile = privateFile(privateCopyName); mExpectedNameHash = hashOf(privateCopyName); mExpectedContentHash = copyAndHashResource( libraryPath("DynamicCodeLoggerNativeTestLibrary.so"), privateCopyFile); System.load(privateCopyFile.toString()); } }.runTest(); } @Test public void testGeneratesEvents_nativeExecutable() throws Exception { new TestNativeCodeWithRetries() { @Override protected void loadNativeCode(int tryNumber) throws Exception { String privateCopyName = "test_executable" + tryNumber; File privateCopyFile = privateFile(privateCopyName); mExpectedNameHash = hashOf(privateCopyName); mExpectedContentHash = copyAndHashResource( "/DynamicCodeLoggerNativeExecutable", privateCopyFile); assertThat(privateCopyFile.setExecutable(true)).isTrue(); Process process = Runtime.getRuntime().exec(privateCopyFile.toString()); int exitCode = process.waitFor(); assertThat(exitCode).isEqualTo(0); } }.runTest(); } @Test public void testGeneratesEvents_spoofed_validFile() throws Exception { File privateCopyFile = privateFile("spoofed"); String expectedContentHash = copyAndHashResource( "/DynamicCodeLoggerNativeExecutable", privateCopyFile); EventLog.writeEvent(EventLog.getTagCode("auditd"), "type=1400 avc: granted { execute_no_trans } " + "path=\"" + privateCopyFile + "\" " + "scontext=u:r:untrusted_app_27: " + "tcontext=u:object_r:app_data_file: " + "tclass=file "); String expectedNameHash = "1CF36F503A02877BB775DC23C1C5A47A95F2684B6A1A83B11795B856D88861E3"; // Run the job to scan generated audit log entries runDynamicCodeLoggingJob(AUDIT_WATCHING_JOB_ID); // And then make sure we log events about it long previousEventNanos = mostRecentEventTimeNanos(); runDynamicCodeLoggingJob(IDLE_LOGGING_JOB_ID); assertDclLoggedSince(previousEventNanos, DCL_NATIVE_SUBTAG, expectedNameHash, expectedContentHash); } @Test public void testGeneratesEvents_spoofed_validFile_untrustedApp() throws Exception { File privateCopyFile = privateFile("spoofed2"); String expectedContentHash = copyAndHashResource( "/DynamicCodeLoggerNativeExecutable", privateCopyFile); EventLog.writeEvent(EventLog.getTagCode("auditd"), "type=1400 avc: granted { execute_no_trans } " + "path=\"" + privateCopyFile + "\" " + "scontext=u:r:untrusted_app: " + "tcontext=u:object_r:app_data_file: " + "tclass=file "); String expectedNameHash = "3E57AA59249154C391316FDCF07C1D499C26A564E4D305833CCD9A98ED895AC9"; // Run the job to scan generated audit log entries runDynamicCodeLoggingJob(AUDIT_WATCHING_JOB_ID); // And then make sure we log events about it long previousEventNanos = mostRecentEventTimeNanos(); runDynamicCodeLoggingJob(IDLE_LOGGING_JOB_ID); assertDclLoggedSince(previousEventNanos, DCL_NATIVE_SUBTAG, expectedNameHash, expectedContentHash); } @Test public void testGeneratesEvents_spoofed_pathTraversal() throws Exception { File privateDir = privateFile("x").getParentFile(); // Transform /a/b/c -> /a/b/c/../../.. so we get back to the root File pathTraversalToRoot = privateDir; File root = new File("/"); while (!privateDir.equals(root)) { pathTraversalToRoot = new File(pathTraversalToRoot, ".."); privateDir = privateDir.getParentFile(); } File spoofedFile = new File(pathTraversalToRoot, "dev/urandom"); assertWithMessage("Expected " + spoofedFile + " to be readable") .that(spoofedFile.canRead()).isTrue(); EventLog.writeEvent(EventLog.getTagCode("auditd"), "type=1400 avc: granted { execute_no_trans } " + "path=\"" + spoofedFile + "\" " + "scontext=u:r:untrusted_app_27: " + "tcontext=u:object_r:app_data_file: " + "tclass=file "); String expectedNameHash = "65528FE876BD676B0DFCC9A8ACA8988E026766F99EEC1E1FB48F46B2F635E225"; // Run the job to scan generated audit log entries runDynamicCodeLoggingJob(AUDIT_WATCHING_JOB_ID); // And then trigger generating DCL events long previousEventNanos = mostRecentEventTimeNanos(); runDynamicCodeLoggingJob(IDLE_LOGGING_JOB_ID); assertNoDclLoggedSince(previousEventNanos, DCL_NATIVE_SUBTAG, expectedNameHash); } @Test public void testGeneratesEvents_spoofed_otherAppFile() throws Exception { File ourPath = sContext.getDatabasePath("android_pay"); File targetPath = new File(ourPath.toString() .replace("com.android.frameworks.dynamiccodeloggertest", "com.google.android.gms")); assertWithMessage("Expected " + targetPath + " to not be readable") .that(targetPath.canRead()).isFalse(); EventLog.writeEvent(EventLog.getTagCode("auditd"), "type=1400 avc: granted { execute_no_trans } " + "path=\"" + targetPath + "\" " + "scontext=u:r:untrusted_app_27: " + "tcontext=u:object_r:app_data_file: " + "tclass=file "); String expectedNameHash = "CBE04E8AB9E7199FC19CBAAF9C774B88E56B3B19E823F2251693380AD6F515E6"; // Run the job to scan generated audit log entries runDynamicCodeLoggingJob(AUDIT_WATCHING_JOB_ID); // And then trigger generating DCL events long previousEventNanos = mostRecentEventTimeNanos(); runDynamicCodeLoggingJob(IDLE_LOGGING_JOB_ID); assertNoDclLoggedSince(previousEventNanos, DCL_NATIVE_SUBTAG, expectedNameHash); } // Abstract out the logic for running a native code loading test multiple times if needed and // leaving time for audit messages to reach the log. private abstract static class TestNativeCodeWithRetries { String mExpectedContentHash; String mExpectedNameHash; abstract void loadNativeCode(int tryNumber) throws Exception; final void runTest() throws Exception { List messages = null; for (int i = 0; i < AUDIT_LOG_RETRIES; i++) { loadNativeCode(i); SystemClock.sleep(i * RETRY_DELAY_MS); // Run the job to scan generated audit log entries runDynamicCodeLoggingJob(AUDIT_WATCHING_JOB_ID); // And then make sure we log events about it long previousEventNanos = mostRecentEventTimeNanos(); runDynamicCodeLoggingJob(IDLE_LOGGING_JOB_ID); messages = findMatchingEvents( previousEventNanos, DCL_NATIVE_SUBTAG, mExpectedNameHash); if (!messages.isEmpty()) { break; } } assertHasDclLog(messages, mExpectedContentHash); } } private static File privateFile(String name) { return new File(sContext.getDir("dcl", Context.MODE_PRIVATE), name); } private String libraryPath(final String libraryName) { // This may be deprecated. but it tells us the ABI of this process which is exactly what we // want. return "/lib/" + Build.CPU_ABI + "/" + libraryName; } private static String copyAndHashResource(String resourcePath, File copyTo) throws Exception { MessageDigest hasher = MessageDigest.getInstance(SHA_256); // Copy the jar from our Java resources to a private data directory Class thisClass = DynamicCodeLoggerIntegrationTests.class; try (InputStream input = thisClass.getResourceAsStream(resourcePath); OutputStream output = new FileOutputStream(copyTo)) { byte[] buffer = new byte[1024]; while (true) { int numRead = input.read(buffer); if (numRead < 0) { break; } output.write(buffer, 0, numRead); hasher.update(buffer, 0, numRead); } } // Compute the SHA-256 of the file content so we can check that it is the same as the value // we see logged. return toHexString(hasher); } private String hashOf(String input) throws Exception { MessageDigest hasher = MessageDigest.getInstance(SHA_256); hasher.update(input.getBytes()); return toHexString(hasher); } private static String toHexString(MessageDigest hasher) { Formatter formatter = new Formatter(); for (byte b : hasher.digest()) { formatter.format("%02X", b); } return formatter.toString(); } private static void runDynamicCodeLoggingJob(int jobId) throws Exception { // This forces the DynamicCodeLoggingService job to start now. runCommand("cmd jobscheduler run -f android " + jobId); // Wait for the job to have run. long startTime = SystemClock.elapsedRealtime(); while (true) { String response = runCommand( "cmd jobscheduler get-job-state android " + jobId); if (!response.contains("pending") && !response.contains("active")) { break; } // Don't wait forever - if it's taken > 10s then something is very wrong. if (SystemClock.elapsedRealtime() - startTime > TimeUnit.SECONDS.toMillis(10)) { throw new AssertionError("Job has not completed: " + response); } SystemClock.sleep(100); } } private static String runCommand(String command) throws Exception { ByteArrayOutputStream response = new ByteArrayOutputStream(); byte[] buffer = new byte[1000]; UiAutomation ui = InstrumentationRegistry.getInstrumentation().getUiAutomation(); ParcelFileDescriptor fd = ui.executeShellCommand(command); try (InputStream input = new ParcelFileDescriptor.AutoCloseInputStream(fd)) { while (true) { int count = input.read(buffer); if (count == -1) { break; } response.write(buffer, 0, count); } } return response.toString("UTF-8"); } private static long mostRecentEventTimeNanos() throws Exception { List events = readSnetEvents(); return events.isEmpty() ? 0 : events.get(events.size() - 1).getTimeNanos(); } private static void assertDclLoggedSince(long previousEventNanos, String expectedSubTag, String expectedNameHash, String expectedContentHash) throws Exception { List messages = findMatchingEvents(previousEventNanos, expectedSubTag, expectedNameHash); assertHasDclLog(messages, expectedContentHash); } private static void assertHasDclLog(List messages, String expectedContentHash) { assertWithMessage("Expected exactly one matching log entry").that(messages).hasSize(1); assertThat(messages.get(0)).endsWith(expectedContentHash); } private static void assertNoDclLoggedSince(long previousEventNanos, String expectedSubTag, String expectedNameHash) throws Exception { List messages = findMatchingEvents(previousEventNanos, expectedSubTag, expectedNameHash); assertWithMessage("Expected no matching log entries").that(messages).isEmpty(); } private static List findMatchingEvents(long previousEventNanos, String expectedSubTag, String expectedNameHash) throws Exception { List messages = new ArrayList<>(); for (Event event : readSnetEvents()) { if (event.getTimeNanos() <= previousEventNanos) { continue; } Object data = event.getData(); if (!(data instanceof Object[])) { continue; } Object[] fields = (Object[]) data; // We only care about DCL events that we generated. String subTag = (String) fields[0]; if (!expectedSubTag.equals(subTag)) { continue; } int uid = (int) fields[1]; if (uid != sMyUid) { continue; } String message = (String) fields[2]; if (!message.startsWith(expectedNameHash)) { continue; } messages.add(message); //assertThat(message).endsWith(expectedContentHash); } return messages; } private static List readSnetEvents() throws Exception { List events = new ArrayList<>(); EventLog.readEvents(new int[] { SNET_TAG }, events); return events; } /** * A class loader that does nothing useful, but importantly doesn't extend BaseDexClassLoader. */ private static class UnknownClassLoader extends ClassLoader { UnknownClassLoader(ClassLoader parent) { super(parent); } } }