commit f1a368d44e9cd1c9714d0e0ccaf9d38ac2768ae1
parent 07daeaf994258139c83e145b5ff290e5aac1493f
Author: rhunk <101876869+rhunk@users.noreply.github.com>
Date: Fri, 3 Nov 2023 04:11:46 +0100
feat: LSPatch obfuscation
Diffstat:
10 files changed, 207 insertions(+), 27 deletions(-)
diff --git 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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<String, File>, //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<String, String>): 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
@@ -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<String, String> = mapOf(),
+)
+
+class LSPatchObfuscation(
+ private val cacheFolder: File,
+ private val printLog: (String) -> Unit = { println(it) }
+) {
+ private fun obfuscateDexFile(dexStrings: Map<String, String?>, 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<String, String>()
+
+ 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
@@ -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
@@ -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
@@ -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) {