From f1a368d44e9cd1c9714d0e0ccaf9d38ac2768ae1 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Fri, 3 Nov 2023 04:11:46 +0100 Subject: [PATCH] feat: LSPatch obfuscation --- .../me/rhunk/snapenhance/core/SnapEnhance.kt | 4 + .../impl/experiments/DeviceSpooferHook.kt | 4 +- .../snapenhance/core/util/LSPatchUpdater.kt | 13 +++ manager/build.gradle.kts | 1 + .../snapenhance/manager/data/SharedConfig.kt | 3 + .../snapenhance/manager/lspatch/LSPatch.kt | 86 ++++++++++---- .../manager/lspatch/LSPatchObfuscation.kt | 107 ++++++++++++++++++ .../manager/lspatch/config/Constants.kt | 1 - .../manager/ui/tab/impl/SettingsTab.kt | 5 + .../ui/tab/impl/download/LSPatchTab.kt | 9 +- 10 files changed, 206 insertions(+), 27 deletions(-) create mode 100644 manager/src/main/kotlin/me/rhunk/snapenhance/manager/lspatch/LSPatchObfuscation.kt diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/SnapEnhance.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/SnapEnhance.kt index 7d2c0cfcf..6837bb1b3 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/SnapEnhance.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/SnapEnhance.kt @@ -64,6 +64,10 @@ class SnapEnhance { } runCatching { LSPatchUpdater.onBridgeConnected(appContext, bridgeClient) + }.onFailure { + logCritical("Failed to init LSPatchUpdater", it) + } + runCatching { measureTimeMillis { runBlocking { init(this) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/DeviceSpooferHook.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/DeviceSpooferHook.kt index 11ef7536b..302f20071 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/DeviceSpooferHook.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/DeviceSpooferHook.kt @@ -5,8 +5,8 @@ import me.rhunk.snapenhance.core.features.FeatureLoadParams import me.rhunk.snapenhance.core.util.hook.HookStage import me.rhunk.snapenhance.core.util.hook.Hooker -class DeviceSpooferHook: Feature("device_spoofer", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { - override fun asyncOnActivityCreate() { +class DeviceSpooferHook: Feature("device_spoofer", loadParams = FeatureLoadParams.INIT_SYNC) { + override fun init() { if (context.config.experimental.spoof.globalState != true) return val fingerprint by context.config.experimental.spoof.device.fingerprint diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/LSPatchUpdater.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/LSPatchUpdater.kt index 54146e818..24a6b03ec 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/LSPatchUpdater.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/LSPatchUpdater.kt @@ -18,11 +18,24 @@ object LSPatchUpdater { } fun onBridgeConnected(context: ModContext, bridgeClient: BridgeClient) { + val obfuscatedModulePath by lazy { + (runCatching { + context::class.java.classLoader?.loadClass("org.lsposed.lspatch.share.Constants") + }.getOrNull())?.declaredFields?.firstOrNull { it.name == "MANAGER_PACKAGE_NAME" }?.also { + it.isAccessible = true + }?.get(null) as? String + } + val embeddedModule = context.androidContext.cacheDir .resolve("lspatch") .resolve(BuildConfig.APPLICATION_ID).let { moduleDir -> if (!moduleDir.exists()) return@let null moduleDir.listFiles()?.firstOrNull { it.extension == "apk" } + } ?: obfuscatedModulePath?.let { path -> + context.androidContext.cacheDir.resolve(path).let dir@{ moduleDir -> + if (!moduleDir.exists()) return@dir null + moduleDir.listFiles()?.firstOrNull { it.extension == "apk" } + } ?: return } ?: return context.log.verbose("Found embedded SE at ${embeddedModule.absolutePath}", TAG) diff --git a/manager/build.gradle.kts b/manager/build.gradle.kts index 60ee6a7f0..cf4481dc6 100644 --- a/manager/build.gradle.kts +++ b/manager/build.gradle.kts @@ -80,6 +80,7 @@ dependencies { implementation(libs.libsu) implementation(libs.guava) implementation(libs.apksig) + implementation(libs.dexlib2) implementation(libs.gson) implementation(libs.jsoup) implementation(libs.okhttp) diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/data/SharedConfig.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/data/SharedConfig.kt index 53541aaf4..815b37a44 100644 --- a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/data/SharedConfig.kt +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/data/SharedConfig.kt @@ -21,4 +21,7 @@ class SharedConfig( var useRootInstaller get() = sharedPreferences.getBoolean("useRootInstaller", false) set(value) = sharedPreferences.edit().putBoolean("useRootInstaller", value).apply() + + var obfuscateLSPatch get() = sharedPreferences.getBoolean("obfuscateLSPatch", false) + set(value) = sharedPreferences.edit().putBoolean("obfuscateLSPatch", value).apply() } \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/lspatch/LSPatch.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/lspatch/LSPatch.kt index 4b052a4e6..903cdf7ad 100644 --- a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/lspatch/LSPatch.kt +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/lspatch/LSPatch.kt @@ -10,7 +10,6 @@ import com.google.gson.Gson import com.wind.meditor.core.ManifestEditor import com.wind.meditor.property.AttributeItem import com.wind.meditor.property.ModificationProperty -import me.rhunk.snapenhance.manager.lspatch.config.Constants.ORIGINAL_APK_ASSET_PATH import me.rhunk.snapenhance.manager.lspatch.config.Constants.PROXY_APP_COMPONENT_FACTORY import me.rhunk.snapenhance.manager.lspatch.config.PatchConfig import me.rhunk.snapenhance.manager.lspatch.util.ApkSignatureHelper @@ -22,28 +21,22 @@ import java.security.cert.X509Certificate import java.util.zip.ZipFile import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.random.Random //https://github.com/LSPosed/LSPatch/blob/master/patch/src/main/java/org/lsposed/patch/LSPatch.java class LSPatch( private val context: Context, private val modules: Map, //packageName -> file + private val obfuscate: Boolean, private val printLog: (Any) -> Unit ) { - companion object { - private val Z_FILE_OPTIONS = ZFileOptions().setAlignmentRule( - AlignmentRules.compose( - AlignmentRules.constantForSuffix(".so", 4096), - AlignmentRules.constantForSuffix(ORIGINAL_APK_ASSET_PATH, 4096) - ) - ) - } - private fun patchManifest(data: ByteArray, lspatchMetadata: String): ByteArray { + private fun patchManifest(data: ByteArray, lspatchMetadata: Pair): ByteArray { val property = ModificationProperty() property.addApplicationAttribute(AttributeItem("appComponentFactory", PROXY_APP_COMPONENT_FACTORY)) - property.addMetaData(ModificationProperty.MetaData("lspatch", lspatchMetadata)) + property.addMetaData(ModificationProperty.MetaData(lspatchMetadata.first, lspatchMetadata.second)) return ByteArrayOutputStream().apply { ManifestEditor(ByteArrayInputStream(data), this, property).processManifest() @@ -70,7 +63,7 @@ class LSPatch( private fun resignApk(inputApkFile: File, outputFile: File) { printLog("Resigning ${inputApkFile.absolutePath} to ${outputFile.absolutePath}") - val dstZFile = ZFile.openReadWrite(outputFile, Z_FILE_OPTIONS) + val dstZFile = ZFile.openReadWrite(outputFile, ZFileOptions()) val inZFile = ZFile.openReadOnly(inputApkFile) inZFile.entries().forEach { entry -> @@ -90,12 +83,42 @@ class LSPatch( printLog("Done") } + private fun uniqueHash(): String { + return Random.nextBytes(Random.nextInt(5, 10)).joinToString("") { "%02x".format(it) } + } + @Suppress("UNCHECKED_CAST") @OptIn(ExperimentalEncodingApi::class) private fun patchApk(inputApkFile: File, outputFile: File) { printLog("Patching ${inputApkFile.absolutePath} to ${outputFile.absolutePath}") - val dstZFile = ZFile.openReadWrite(outputFile, Z_FILE_OPTIONS) - val sourceApkFile = dstZFile.addNestedZip({ ORIGINAL_APK_ASSET_PATH }, inputApkFile, false) + + val obfuscationCacheFolder = File(context.cacheDir, "lspatch").apply { + if (exists()) deleteRecursively() + mkdirs() + } + val lspatchObfuscation = LSPatchObfuscation(obfuscationCacheFolder) { printLog(it) } + val dexObfuscationConfig = if (obfuscate) DexObfuscationConfig( + packageName = uniqueHash(), + metadataManifestField = uniqueHash(), + metaLoaderFilePath = uniqueHash(), + configFilePath = uniqueHash(), + loaderFilePath = uniqueHash(), + libNativeFilePath = mapOf( + "arm64-v8a" to uniqueHash() + ".so", + "armeabi-v7a" to uniqueHash() + ".so", + ), + originApkPath = uniqueHash(), + cachedOriginApkPath = uniqueHash(), + openAtApkPath = uniqueHash(), + assetModuleFolderPath = uniqueHash(), + ) else null + + val dstZFile = ZFile.openReadWrite(outputFile, ZFileOptions().setAlignmentRule( + AlignmentRules.compose( + AlignmentRules.constantForSuffix(".so", 4096), + AlignmentRules.constantForSuffix("assets/" + (dexObfuscationConfig?.originApkPath ?: "lspatch/origin.apk"), 4096) + ) + )) val patchConfig = PatchConfig( useManager = false, @@ -115,32 +138,37 @@ class LSPatch( printLog("Patching manifest") + val sourceApkFile = dstZFile.addNestedZip({ "assets/" + (dexObfuscationConfig?.originApkPath ?: "lspatch/origin.apk") }, inputApkFile, false) val originalManifestEntry = sourceApkFile.get("AndroidManifest.xml") ?: throw Exception("No original manifest found") originalManifestEntry.open().use { inputStream -> - val patchedManifestData = patchManifest(inputStream.readBytes(), Base64.encode(patchConfig.toByteArray())) + val patchedManifestData = patchManifest(inputStream.readBytes(), (dexObfuscationConfig?.metadataManifestField ?: "lspatch") to Base64.encode(patchConfig.toByteArray())) dstZFile.add("AndroidManifest.xml", patchedManifestData.inputStream()) } //add config printLog("Adding config") - dstZFile.add("assets/lspatch/config.json", ByteArrayInputStream(patchConfig.toByteArray())) + dstZFile.add("assets/" + (dexObfuscationConfig?.configFilePath ?: "lspatch/config.json"), ByteArrayInputStream(patchConfig.toByteArray())) // add loader dex - printLog("Adding dex files") - dstZFile.add("classes.dex", context.assets.open("lspatch/dexes/metaloader.dex")) - dstZFile.add("assets/lspatch/loader.dex", context.assets.open("lspatch/dexes/loader.dex")) + printLog("Adding loader dex") + context.assets.open("lspatch/dexes/loader.dex").use { inputStream -> + dstZFile.add("assets/" + (dexObfuscationConfig?.loaderFilePath ?: "lspatch/loader.dex"), dexObfuscationConfig?.let { + lspatchObfuscation.obfuscateLoader(inputStream, it).inputStream() + } ?: inputStream) + } //add natives printLog("Adding natives") context.assets.list("lspatch/so")?.forEach { native -> - dstZFile.add("assets/lspatch/so/$native/liblspatch.so", context.assets.open("lspatch/so/$native/liblspatch.so"), false) + dstZFile.add("assets/${dexObfuscationConfig?.libNativeFilePath?.get(native) ?: "lspatch/so/$native/liblspatch.so"}", context.assets.open("lspatch/so/$native/liblspatch.so"), false) } //embed modules printLog("Embedding modules") modules.forEach { (packageName, module) -> - printLog("- $packageName") - dstZFile.add("assets/lspatch/modules/$packageName.apk", module.inputStream()) + val obfuscatedPackageName = dexObfuscationConfig?.packageName ?: packageName + printLog("- $obfuscatedPackageName") + dstZFile.add("assets/${dexObfuscationConfig?.assetModuleFolderPath ?: "lspatch/modules"}/$obfuscatedPackageName.apk", module.inputStream()) } // link apk entries @@ -148,7 +176,7 @@ class LSPatch( for (entry in sourceApkFile.entries()) { val name = entry.centralDirectoryHeader.name - if (name.startsWith("classes") && name.endsWith(".dex")) continue + if (dexObfuscationConfig == null && name.startsWith("classes") && name.endsWith(".dex")) continue if (dstZFile[name] != null) continue if (name == "AndroidManifest.xml") continue if (name.startsWith("META-INF") && (name.endsWith(".SF") || name.endsWith(".MF") || name.endsWith( @@ -158,8 +186,20 @@ class LSPatch( sourceApkFile.addFileLink(name, name) } + printLog("Adding meta loader dex") + context.assets.open("lspatch/dexes/metaloader.dex").use { inputStream -> + dstZFile.add(dexObfuscationConfig?.let { "classes9.dex" } ?: "classes.dex", dexObfuscationConfig?.let { + lspatchObfuscation.obfuscateMetaLoader(inputStream, it).inputStream() + } ?: inputStream) + } + + printLog("Writing apk") dstZFile.realign() dstZFile.close() + sourceApkFile.close() + + printLog("Cleaning obfuscation cache") + obfuscationCacheFolder.deleteRecursively() printLog("Done") } diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/lspatch/LSPatchObfuscation.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/lspatch/LSPatchObfuscation.kt new file mode 100644 index 000000000..d478e2c8c --- /dev/null +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/lspatch/LSPatchObfuscation.kt @@ -0,0 +1,107 @@ +package me.rhunk.snapenhance.manager.lspatch + +import org.jf.dexlib2.Opcodes +import org.jf.dexlib2.dexbacked.DexBackedDexFile +import org.jf.dexlib2.iface.reference.StringReference +import org.jf.dexlib2.writer.io.FileDataStore +import org.jf.dexlib2.writer.pool.DexPool +import org.jf.dexlib2.writer.pool.StringPool +import java.io.BufferedInputStream +import java.io.File +import java.io.InputStream + +data class DexObfuscationConfig( + val packageName: String, + val metadataManifestField: String? = null, + val metaLoaderFilePath: String? = null, + val configFilePath: String? = null, + val loaderFilePath: String? = null, + val originApkPath: String? = null, + val cachedOriginApkPath: String? = null, + val openAtApkPath: String? = null, + val assetModuleFolderPath: String? = null, + val libNativeFilePath: Map = mapOf(), +) + +class LSPatchObfuscation( + private val cacheFolder: File, + private val printLog: (String) -> Unit = { println(it) } +) { + private fun obfuscateDexFile(dexStrings: Map, inputStream: InputStream): File { + val dexFile = DexBackedDexFile.fromInputStream(Opcodes.forApi(29), BufferedInputStream(inputStream)) + + val dexPool = object: DexPool(dexFile.opcodes) { + override fun getSectionProvider(): SectionProvider { + val dexPool = this + return object: DexPoolSectionProvider() { + override fun getStringSection() = object: StringPool(dexPool) { + private val cacheMap = mutableMapOf() + + override fun intern(string: CharSequence) { + dexStrings[string.toString()]?.let { + cacheMap[string.toString()] = it + printLog("mapping $string to $it") + super.intern(it) + return + } + super.intern(string) + } + + override fun getItemIndex(key: CharSequence): Int { + return cacheMap[key.toString()]?.let { + internedItems[it] + } ?: super.getItemIndex(key) + } + + override fun getItemIndex(key: StringReference): Int { + return cacheMap[key.toString()]?.let { + internedItems[it] + } ?: super.getItemIndex(key) + } + } + } + } + } + dexFile.classes.forEach { dexBackedClassDef -> + dexPool.internClass(dexBackedClassDef) + } + val outputFile = File.createTempFile("obf", ".dex", cacheFolder) + dexPool.writeTo(FileDataStore(outputFile)) + return outputFile + } + + + fun obfuscateMetaLoader(inputStream: InputStream, config: DexObfuscationConfig): File { + return obfuscateDexFile(mapOf( + "assets/lspatch/config.json" to "assets/${config.configFilePath}", + "assets/lspatch/loader.dex" to "assets/${config.loaderFilePath}", + ) + (config.libNativeFilePath.takeIf { it.isNotEmpty() }?.let { + mapOf( + "!/assets/lspatch/so/" to "!/assets/", + "assets/lspatch/so/" to "assets/", + "/liblspatch.so" to "", + "arm64-v8a" to config.libNativeFilePath["arm64-v8a"], + "armeabi-v7a" to config.libNativeFilePath["armeabi-v7a"], + "x86" to config.libNativeFilePath["x86"], + "x86_64" to config.libNativeFilePath["x86_64"], + ) + } ?: mapOf()), inputStream) + } + + fun obfuscateLoader(inputStream: InputStream, config: DexObfuscationConfig): File { + return obfuscateDexFile(mapOf( + "assets/lspatch/config.json" to config.configFilePath?.let { "assets/$it" }, + "assets/lspatch/loader.dex" to config.loaderFilePath?.let { "assets/$it" }, + "assets/lspatch/metaloader.dex" to config.metaLoaderFilePath?.let { "assets/$it" }, + "assets/lspatch/origin.apk" to config.originApkPath?.let { "assets/$it" }, + "/lspatch/origin/" to config.cachedOriginApkPath?.let { "/$it/" }, // context.getCacheDir() + ==> "/lspatch/origin/" <== + sourceFile.getEntry(ORIGINAL_APK_ASSET_PATH).getCrc() + ".apk"; + "/lspatch/" to config.cachedOriginApkPath?.let { "/$it/" }, // context.getCacheDir() + "/lspatch/" + packageName + "/" + "cache/lspatch/origin/" to config.cachedOriginApkPath?.let { "cache/$it" }, //LSPApplication => Path originPath = Paths.get(appInfo.dataDir, "cache/lspatch/origin/"); + "assets/lspatch/modules/" to config.assetModuleFolderPath?.let { "assets/$it/" }, // Constants.java => EMBEDDED_MODULES_ASSET_PATH + "lspatch/modules" to config.assetModuleFolderPath, // LocalApplicationService.java => context.getAssets().list("lspatch/modules"), + "lspatch/modules/" to config.assetModuleFolderPath?.let { "$it/" }, // LocalApplicationService.java => try (var is = context.getAssets().open("lspatch/modules/" + name)) { + "lspatch" to config.metadataManifestField, // SigBypass.java => "lspatch", + "org.lsposed.lspatch" to config.cachedOriginApkPath?.let { "$it/${config.packageName}/" }, // Constants.java => "org.lsposed.lspatch", (Used in LSPatchUpdater.kt) + ), inputStream) + } +} \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/lspatch/config/Constants.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/lspatch/config/Constants.kt index 0af8c9de4..adcd943ae 100644 --- a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/lspatch/config/Constants.kt +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/lspatch/config/Constants.kt @@ -2,7 +2,6 @@ package me.rhunk.snapenhance.manager.lspatch.config //https://github.com/LSPosed/LSPatch/blob/master/share/java/src/main/java/org/lsposed/lspatch/share/Constants.java object Constants { - const val ORIGINAL_APK_ASSET_PATH = "assets/lspatch/origin.apk" const val PROXY_APP_COMPONENT_FACTORY = "org.lsposed.lspatch.metaloader.LSPAppComponentFactoryStub" } \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/SettingsTab.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/SettingsTab.kt index 8db18b7eb..30badeb17 100644 --- a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/SettingsTab.kt +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/SettingsTab.kt @@ -145,6 +145,11 @@ class SettingsTab : Tab("settings", isPrimary = true, icon = Icons.Default.Setti setValue = { sharedConfig.useRootInstaller = it }, label = "Use root installer" ) + ConfigBooleanRow( + getValue = { sharedConfig.obfuscateLSPatch }, + setValue = { sharedConfig.obfuscateLSPatch = it }, + label = "Obfuscate LSPatch (experimental)" + ) } } } \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/LSPatchTab.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/LSPatchTab.kt index d5e359339..3d26eff71 100644 --- a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/LSPatchTab.kt +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/LSPatchTab.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import me.rhunk.snapenhance.manager.data.APKMirror import me.rhunk.snapenhance.manager.data.DownloadItem @@ -85,7 +86,7 @@ class LSPatchTab : Tab("lspatch") { sharedConfig.snapEnhancePackageName to module, ), printLog = { log("[LSPatch] $it") - }) + }, obfuscate = sharedConfig.obfuscateLSPatch) log("== Patching apk ==") val outputFiles = lsPatch.patchSplits(listOf(apkFile!!)) @@ -138,6 +139,12 @@ class LSPatchTab : Tab("lspatch") { } } + DisposableEffect(Unit) { + onDispose { + coroutineScope.cancel() + } + } + val scrollState = rememberScrollState() fun triggerInstallation(shouldUninstall: Boolean) {