Skip to content

Commit

Permalink
feat: LSPatch obfuscation
Browse files Browse the repository at this point in the history
  • Loading branch information
rhunk committed Nov 3, 2023
1 parent 07daeaf commit f1a368d
Show file tree
Hide file tree
Showing 10 changed files with 206 additions and 27 deletions.
4 changes: 4 additions & 0 deletions core/src/main/kotlin/me/rhunk/snapenhance/core/SnapEnhance.kt
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ class SnapEnhance {
}
runCatching {
LSPatchUpdater.onBridgeConnected(appContext, bridgeClient)
}.onFailure {
logCritical("Failed to init LSPatchUpdater", it)
}
runCatching {
measureTimeMillis {
runBlocking {
init(this)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions manager/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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 ->
Expand All @@ -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,
Expand All @@ -115,40 +138,45 @@ 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
printLog("Linking apk entries")

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(
Expand All @@ -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")
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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!!))
Expand Down Expand Up @@ -138,6 +139,12 @@ class LSPatchTab : Tab("lspatch") {
}
}

DisposableEffect(Unit) {
onDispose {
coroutineScope.cancel()
}
}

val scrollState = rememberScrollState()

fun triggerInstallation(shouldUninstall: Boolean) {
Expand Down

0 comments on commit f1a368d

Please sign in to comment.