Add new tests for app hiding

This commit is contained in:
topjohnwu 2024-12-17 22:11:01 -08:00 committed by John Wu
parent 820710c086
commit 5885b8c20d
15 changed files with 271 additions and 168 deletions

View File

@ -37,13 +37,4 @@
-flattenpackagehierarchy
-allowaccessmodification
-dontwarn org.junit.Assert
-dontwarn org.bouncycastle.jsse.BCSSLParameters
-dontwarn org.bouncycastle.jsse.BCSSLSocket
-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider
-dontwarn org.commonmark.ext.gfm.strikethrough.Strikethrough
-dontwarn org.conscrypt.Conscrypt*
-dontwarn org.conscrypt.ConscryptHostnameVerifier
-dontwarn org.openjsse.javax.net.ssl.SSLParameters
-dontwarn org.openjsse.javax.net.ssl.SSLSocket
-dontwarn org.openjsse.net.ssl.OpenJSSE
-dontwarn org.junit.**

View File

@ -1,50 +0,0 @@
package com.topjohnwu.magisk.core
import androidx.annotation.Keep
import com.topjohnwu.magisk.core.di.ServiceLocator
import com.topjohnwu.magisk.core.tasks.MagiskInstaller
import com.topjohnwu.magisk.core.utils.RootUtils
import com.topjohnwu.superuser.CallbackList
import com.topjohnwu.superuser.Shell
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertTrue
import timber.log.Timber
/**
* We implement all test logic here and mark it with @Keep so that our instrumentation package
* can properly run tests on fully obfuscated release APKs.
*/
@Keep
object TestImpl {
fun before() {
assertTrue("Should have root access", Shell.getShell().isRoot)
// Make sure the root service is running
RootUtils.Connection.await()
}
object LogList : CallbackList<String>(Runnable::run) {
override fun onAddElement(e: String) {
Timber.i(e)
}
}
fun setupMagisk() {
runBlocking {
MagiskInstaller.Emulator(LogList, LogList).exec()
}
}
fun setupShellGrantTest() {
// Clear existing grant for ADB shell
runBlocking {
ServiceLocator.policyDB.delete(2000)
Config.suAutoResponse = Config.Value.SU_AUTO_ALLOW
Config.prefs.edit().commit()
}
}
fun testZygisk() {
assertTrue("Zygisk should be enabled", Info.isZygiskEnabled)
}
}

View File

@ -1,17 +1,18 @@
package com.topjohnwu.magisk.core.model.su
class SuPolicy(val uid: Int) {
class SuPolicy(
val uid: Int,
var policy: Int = INTERACTIVE,
var until: Long = -1L,
var logging: Boolean = true,
var notification: Boolean = true,
) {
companion object {
const val INTERACTIVE = 0
const val DENY = 1
const val ALLOW = 2
}
var policy: Int = INTERACTIVE
var until: Long = -1L
var logging: Boolean = true
var notification: Boolean = true
fun toMap(): MutableMap<String, Any> = mutableMapOf(
"uid" to uid,
"policy" to policy,

View File

@ -62,7 +62,7 @@ class SuRequestHandler(
return false
}
output = File(fifo)
policy = SuPolicy(uid)
policy = policyDB.fetch(uid) ?: SuPolicy(uid)
try {
pkgInfo = pm.getPackageInfo(uid, pid) ?: PackageInfo().apply {
val name = pm.getNameForUid(uid) ?: throw PackageManager.NameNotFoundException()

View File

@ -4,6 +4,7 @@ import android.app.Activity
import android.app.ActivityOptions
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.widget.Toast
import com.topjohnwu.magisk.StubApk
@ -25,7 +26,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.OutputStream
import java.security.SecureRandom
@ -36,6 +36,7 @@ object AppMigration {
private const val ALPHA = "abcdefghijklmnopqrstuvwxyz"
private const val ALPHADOTS = "$ALPHA....."
private const val ANDROID_MANIFEST = "AndroidManifest.xml"
private const val TEST_PKG_NAME = "$APP_PACKAGE_NAME.test"
// Some arbitrary limit
const val MAX_LABEL_LENGTH = 32
@ -131,21 +132,15 @@ object AppMigration {
val je = jar.getJarEntry(ANDROID_MANIFEST)
val xml = AXML(jar.getRawData(je))
val generator = classNameGenerator()
if (!xml.patchStrings {
for (i in it.indices) {
val s = it[i]
if (s.contains(APP_PACKAGE_NAME)) {
it[i] = s.replace(APP_PACKAGE_NAME, pkg)
} else if (s.contains(PLACEHOLDER)) {
it[i] = generator.next()
} else if (s == origLabel) {
it[i] = label.toString()
}
val p = xml.patchStrings {
when {
it.contains(APP_PACKAGE_NAME) -> it.replace(APP_PACKAGE_NAME, pkg)
it.contains(PLACEHOLDER) -> generator.next()
it == origLabel -> label.toString()
else -> it
}
}) {
return false
}
if (!p) return false
// Write apk changes
jar.getOutputStream(je).use { it.write(xml.bytes) }
@ -159,40 +154,83 @@ object AppMigration {
}
}
private fun launchApp(activity: Activity, pkg: String) {
val intent = activity.packageManager.getLaunchIntentForPackage(pkg) ?: return
private fun patchTest(apk: File, out: File, pkg: String): Boolean {
try {
JarMap.open(apk, true).use { jar ->
val je = jar.getJarEntry(ANDROID_MANIFEST)
val xml = AXML(jar.getRawData(je))
val p = xml.patchStrings {
when (it) {
APP_PACKAGE_NAME -> pkg
TEST_PKG_NAME -> "$pkg.test"
else -> it
}
}
if (!p) return false
// Write apk changes
jar.getOutputStream(je).use { it.write(xml.bytes) }
val keys = Keygen()
out.outputStream().use { SignApk.sign(keys.cert, keys.key, jar, it) }
return true
}
} catch (e: Exception) {
Timber.e(e)
return false
}
}
private fun launchApp(context: Context, pkg: String) {
val intent = context.packageManager.getLaunchIntentForPackage(pkg) ?: return
intent.putExtra(Const.Key.PREV_CONFIG, Config.toBundle())
val options = ActivityOptions.makeBasic()
if (Build.VERSION.SDK_INT >= 34) {
options.setShareIdentityEnabled(true)
}
activity.startActivity(intent, options.toBundle())
activity.finish()
context.startActivity(intent, options.toBundle())
if (context is Activity) {
context.finish()
}
}
private suspend fun patchAndHide(activity: Activity, label: String): Boolean {
val stub = File(activity.cacheDir, "stub.apk")
suspend fun patchAndHide(context: Context, label: String, pkg: String? = null): Boolean {
val stub = File(context.cacheDir, "stub.apk")
try {
activity.assets.open("stub.apk").writeTo(stub)
context.assets.open("stub.apk").writeTo(stub)
} catch (e: IOException) {
Timber.e(e)
return false
}
// Generate a new random package name and signature
val repack = File(activity.cacheDir, "patched.apk")
val pkg = genPackageName()
// Generate a new random signature and package name if needed
val pkg = pkg ?: genPackageName()
Config.keyStoreRaw = ""
if (!patch(activity, stub, FileOutputStream(repack), pkg, label))
return false
// Check and patch the test APK
try {
val info = context.packageManager.getApplicationInfo(TEST_PKG_NAME, 0)
val testApk = File(info.sourceDir)
val testRepack = File(context.cacheDir, "test.apk")
if (!patchTest(testApk, testRepack, pkg))
return false
val cmd = "adb_pm_install $testRepack $pkg.test"
if (!Shell.cmd(cmd).exec().isSuccess)
return false
} catch (e: PackageManager.NameNotFoundException) {
}
val repack = File(context.cacheDir, "patched.apk")
repack.outputStream().use {
if (!patch(context, stub, it, pkg, label))
return false
}
// Install and auto launch app
val cmd = "adb_pm_install $repack $pkg"
if (Shell.cmd(cmd).exec().isSuccess) {
Config.suManager = pkg
Shell.cmd("touch $AppApkPath").exec()
launchApp(activity, pkg)
launchApp(context, pkg)
return true
} else {
return false
@ -216,6 +254,18 @@ object AppMigration {
}
}
suspend fun restoreApp(context: Context): Boolean {
val apk = StubApk.current(context)
val cmd = "adb_pm_install $apk $APP_PACKAGE_NAME"
if (Shell.cmd(cmd).await().isSuccess) {
Config.suManager = ""
Shell.cmd("touch $AppApkPath").exec()
launchApp(context, APP_PACKAGE_NAME)
return true
}
return false
}
@Suppress("DEPRECATION")
suspend fun restore(activity: Activity) {
val dialog = android.app.ProgressDialog(activity).apply {
@ -224,13 +274,7 @@ object AppMigration {
setCancelable(false)
show()
}
val apk = StubApk.current(activity)
val cmd = "adb_pm_install $apk $APP_PACKAGE_NAME"
if (Shell.cmd(cmd).await().isSuccess) {
Config.suManager = ""
Shell.cmd("touch $AppApkPath").exec()
launchApp(activity, APP_PACKAGE_NAME)
} else {
if (!restoreApp(activity)) {
activity.toast(R.string.failure, Toast.LENGTH_LONG)
}
dialog.dismiss()

View File

@ -29,7 +29,7 @@ class AXML(b: ByteArray) {
* Followed by an array of uint32_t with size = number of strings
* Each entry points to an offset into the string data
*/
fun patchStrings(patchFn: (Array<String>) -> Unit): Boolean {
fun patchStrings(mapFn: (String) -> String): Boolean {
val buffer = ByteBuffer.wrap(bytes).order(LITTLE_ENDIAN)
fun findStringPool(): Int {
@ -65,7 +65,9 @@ class AXML(b: ByteArray) {
}
val strArr = strList.toTypedArray()
patchFn(strArr)
for (i in strArr.indices) {
strArr[i] = mapFn(strArr[i])
}
// Write everything before string data, will patch values later
val baos = RawByteStream()

View File

@ -0,0 +1,87 @@
package com.topjohnwu.magisk.test
import androidx.annotation.Keep
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.topjohnwu.magisk.core.BuildConfig.APP_PACKAGE_NAME
import com.topjohnwu.magisk.core.Config
import com.topjohnwu.magisk.core.di.ServiceLocator
import com.topjohnwu.magisk.core.model.su.SuPolicy
import com.topjohnwu.magisk.core.tasks.AppMigration
import com.topjohnwu.magisk.core.tasks.MagiskInstaller
import com.topjohnwu.superuser.CallbackList
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertTrue
import org.junit.BeforeClass
import org.junit.Test
import org.junit.runner.RunWith
import timber.log.Timber
@Keep
@RunWith(AndroidJUnit4::class)
class Environment {
companion object {
@BeforeClass
@JvmStatic
fun before() = MagiskAppTest.before()
}
@Test
fun setupMagisk() {
val log = object : CallbackList<String>(Runnable::run) {
override fun onAddElement(e: String) {
Timber.i(e)
}
}
runBlocking {
assertTrue(
"Magisk setup failed",
MagiskInstaller.Emulator(log, log).exec()
)
}
}
@Test
fun setupShellGrantTest() {
runBlocking {
// Inject an undetermined + mute logging policy for ADB shell
val policy = SuPolicy(
uid = 2000,
logging = false,
notification = false,
until = 0L
)
ServiceLocator.policyDB.update(policy)
// Bypass the need to actually show a dialog
Config.suAutoResponse = Config.Value.SU_AUTO_ALLOW
Config.prefs.edit().commit()
}
}
@Test
fun setupAppHide() {
runBlocking {
assertTrue(
"App hiding failed",
AppMigration.patchAndHide(
context = InstrumentationRegistry.getInstrumentation().targetContext,
label = "Settings",
pkg = "repackaged.$APP_PACKAGE_NAME"
)
)
}
}
@Test
fun setupAppRestore() {
runBlocking {
assertTrue(
"App restoration failed",
AppMigration.restoreApp(
context = InstrumentationRegistry.getInstrumentation().targetContext
)
)
}
}
}

View File

@ -0,0 +1,31 @@
package com.topjohnwu.magisk.test
import androidx.annotation.Keep
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.topjohnwu.magisk.core.Info
import com.topjohnwu.magisk.core.utils.RootUtils
import com.topjohnwu.superuser.Shell
import org.junit.Assert.assertTrue
import org.junit.BeforeClass
import org.junit.Test
import org.junit.runner.RunWith
@Keep
@RunWith(AndroidJUnit4::class)
class MagiskAppTest {
companion object {
@BeforeClass
@JvmStatic
fun before() {
assertTrue("Should have root access", Shell.getShell().isRoot)
// Make sure the root service is running
RootUtils.Connection.await()
}
}
@Test
fun testZygisk() {
assertTrue("Zygisk should be enabled", Info.isZygiskEnabled)
}
}

View File

@ -12,7 +12,7 @@ import dalvik.system.BaseDexClassLoader;
public class DynamicClassLoader extends BaseDexClassLoader {
public DynamicClassLoader(File apk) {
this(apk, getSystemClassLoader());
this(apk, DynamicClassLoader.class.getClassLoader());
}
public DynamicClassLoader(File apk, ClassLoader parent) {

View File

@ -9,7 +9,7 @@
</application>
<instrumentation
android:name="androidx.test.runner.AndroidJUnitRunner"
android:name="com.topjohnwu.magisk.test.TestRunner"
android:targetPackage="com.topjohnwu.magisk"
android:label="Tests for Magisk" />

View File

@ -1,29 +0,0 @@
package com.topjohnwu.magisk.test
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.topjohnwu.magisk.core.TestImpl
import org.junit.BeforeClass
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class Environment {
companion object {
@BeforeClass
@JvmStatic
fun before() {
TestImpl.before()
}
}
@Test
fun setupMagisk() {
TestImpl.setupMagisk()
}
@Test
fun setupShellGrantTest() {
TestImpl.setupShellGrantTest()
}
}

View File

@ -1,24 +0,0 @@
package com.topjohnwu.magisk.test
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.topjohnwu.magisk.core.TestImpl
import org.junit.BeforeClass
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class MagiskAppTest {
companion object {
@BeforeClass
@JvmStatic
fun before() {
TestImpl.before()
}
}
@Test
fun testZygisk() {
TestImpl.testZygisk()
}
}

View File

@ -0,0 +1,18 @@
package com.topjohnwu.magisk.test
import android.os.Bundle
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.runner.AndroidJUnitRunner
class TestRunner : AndroidJUnitRunner() {
override fun onCreate(arguments: Bundle) {
// Force using the target context's classloader to run tests
arguments.putString("classLoader", TestClassLoader::class.java.name)
super.onCreate(arguments)
}
}
private val targetClassLoader inline get() =
InstrumentationRegistry.getInstrumentation().targetContext.classLoader
class TestClassLoader : ClassLoader(targetClassLoader)

View File

@ -80,7 +80,7 @@ void su_info::check_db() {
}
// We need to check our manager
if (access.log || access.notify) {
if (access.policy == QUERY || access.log || access.notify) {
mgr_uid = get_manager(to_user_id(eval_uid), &mgr_pkg, true);
}
}

View File

@ -7,7 +7,6 @@ export PATH="$PATH:$ANDROID_HOME/platform-tools"
emu="$ANDROID_HOME/emulator/emulator"
sdk="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager"
avd="$ANDROID_HOME/cmdline-tools/latest/bin/avdmanager"
test_pkg='com.topjohnwu.magisk.test'
boot_timeout=600
@ -24,14 +23,27 @@ print_error() {
echo -e "\n\033[41;39m${1}\033[0m\n"
}
run_instrument_tests() {
local out=$(adb shell am instrument -w \
--user 0 \
-e class "$1" \
com.topjohnwu.magisk.test/androidx.test.runner.AndroidJUnitRunner)
# $1 = TestClass#method
# $2: boolean = isRepackaged
run_instrument_test() {
local test_pkg
if [ -n "$2" -a $2 ]; then
test_pkg="repackaged.com.topjohnwu.magisk.test"
else
test_pkg=com.topjohnwu.magisk.test
fi
local out=$(adb shell am instrument -w --user 0 \
-e class "com.topjohnwu.magisk.test.$1" \
"$test_pkg/com.topjohnwu.magisk.test.TestRunner")
grep -q 'OK (' <<< "$out"
}
# $1 = pkg
wait_for_pm() {
sleep 5
adb shell pm uninstall $1 || true
}
test_setup() {
local variant=$1
adb shell 'PATH=$PATH:/debug_ramdisk magisk -v'
@ -43,14 +55,34 @@ test_setup() {
adb install -r -g out/test-${variant}.apk
# Run setup through the test app
run_instrument_tests "$test_pkg.Environment#setupMagisk"
run_instrument_test 'Environment#setupMagisk'
}
test_app() {
# Run app tests
run_instrument_tests "$test_pkg.MagiskAppTest"
run_instrument_test 'MagiskAppTest'
# Test shell su request
run_instrument_tests "$test_pkg.Environment#setupShellGrantTest"
run_instrument_test 'Environment#setupShellGrantTest'
adb shell /system/xbin/su 2000 su -c id | tee /dev/fd/2 | grep -q 'uid=0'
adb shell am force-stop com.topjohnwu.magisk
# Test app hiding
run_instrument_test 'Environment#setupAppHide'
wait_for_pm com.topjohnwu.magisk
# Make sure it still works
run_instrument_test 'MagiskAppTest' true
# Test shell su request
run_instrument_test 'Environment#setupShellGrantTest' true
adb shell /system/xbin/su 2000 su -c id | tee /dev/fd/2 | grep -q 'uid=0'
adb shell am force-stop repackaged.com.topjohnwu.magisk
# Test app restore
run_instrument_test 'Environment#setupAppRestore' true
wait_for_pm repackaged.com.topjohnwu.magisk
# Make sure it still works
run_instrument_test 'MagiskAppTest'
}