commit 0f1cd7157aa9aa64b9fdd1564794452bcda89b2e parent dc30d4ee254581ba86d69742f5ae5d33cbefcc94 Author: rhunk <101876869+rhunk@users.noreply.github.com> Date: Sat, 11 Nov 2023 18:15:27 +0100 feat: randomize package name Diffstat:
25 files changed, 788 insertions(+), 564 deletions(-)
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt @@ -126,10 +126,10 @@ class RemoteSideContext( }, modInfo = ModInfo( loaderPackageName = MainActivity::class.java.`package`?.name, - buildPackageName = BuildConfig.APPLICATION_ID, + buildPackageName = androidContext.packageName, buildVersion = BuildConfig.VERSION_NAME, buildVersionCode = BuildConfig.VERSION_CODE.toLong(), - buildIssuer = androidContext.packageManager.getPackageInfo(BuildConfig.APPLICATION_ID, PackageManager.GET_SIGNING_CERTIFICATES) + buildIssuer = androidContext.packageManager.getPackageInfo(androidContext.packageName, PackageManager.GET_SIGNING_CERTIFICATES) ?.signingInfo?.apkContentsSigners?.firstOrNull()?.let { val certFactory = CertificateFactory.getInstance("X509") val cert = certFactory.generateCertificate(ByteArrayInputStream(it.toByteArray())) as X509Certificate diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/Constants.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/Constants.kt @@ -2,8 +2,6 @@ package me.rhunk.snapenhance.common object Constants { val SNAPCHAT_PACKAGE_NAME get() = "com.snapchat.android" - - val ARROYO_MEDIA_CONTAINER_PROTO_PATH = intArrayOf(4, 4) - + val SE_PACKAGE_NAME get() = BuildConfig.APPLICATION_ID const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" } \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/ReceiversConfig.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/ReceiversConfig.kt @@ -1,7 +1,7 @@ package me.rhunk.snapenhance.common object ReceiversConfig { - const val BRIDGE_SYNC_ACTION = BuildConfig.APPLICATION_ID + ".core.bridge.SYNC" + const val BRIDGE_SYNC_ACTION = "me.rhunk.snapenhance.core.bridge.SYNC" const val DOWNLOAD_REQUEST_EXTRA = "request" const val DOWNLOAD_METADATA_EXTRA = "metadata" const val MESSAGING_PREVIEW_EXTRA = "messaging_preview" diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/BridgeClient.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/BridgeClient.kt @@ -18,7 +18,7 @@ import me.rhunk.snapenhance.bridge.SyncCallback import me.rhunk.snapenhance.bridge.e2ee.E2eeInterface import me.rhunk.snapenhance.bridge.scripting.IScripting import me.rhunk.snapenhance.bridge.snapclient.MessagingBridge -import me.rhunk.snapenhance.common.BuildConfig +import me.rhunk.snapenhance.common.Constants import me.rhunk.snapenhance.common.bridge.FileLoaderWrapper import me.rhunk.snapenhance.common.bridge.types.BridgeFileType import me.rhunk.snapenhance.common.bridge.types.FileActionType @@ -51,7 +51,7 @@ class BridgeClient( with(context.androidContext) { runCatching { startActivity(Intent() - .setClassName(BuildConfig.APPLICATION_ID, BuildConfig.APPLICATION_ID + ".bridge.ForceStartActivity") + .setClassName(Constants.SE_PACKAGE_NAME, "me.rhunk.snapenhance.bridge.ForceStartActivity") .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK) ) } @@ -59,7 +59,7 @@ class BridgeClient( //ensure the remote process is running runCatching { val intent = Intent() - .setClassName(BuildConfig.APPLICATION_ID, BuildConfig.APPLICATION_ID + ".bridge.BridgeService") + .setClassName(Constants.SE_PACKAGE_NAME,"me.rhunk.snapenhance.bridge.BridgeService") if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { bindService( intent, 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 @@ -1,6 +1,6 @@ package me.rhunk.snapenhance.core.util -import me.rhunk.snapenhance.common.BuildConfig +import me.rhunk.snapenhance.common.Constants import me.rhunk.snapenhance.core.ModContext import me.rhunk.snapenhance.core.bridge.BridgeClient import java.io.File @@ -28,7 +28,7 @@ object LSPatchUpdater { val embeddedModule = context.androidContext.cacheDir .resolve("lspatch") - .resolve(BuildConfig.APPLICATION_ID).let { moduleDir -> + .resolve(Constants.SE_PACKAGE_NAME).let { moduleDir -> if (!moduleDir.exists()) return@let null moduleDir.listFiles()?.firstOrNull { it.extension == "apk" } } ?: obfuscatedModulePath?.let { path -> 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 @@ -1,6 +1,7 @@ package me.rhunk.snapenhance.manager.data import android.content.Context +import me.rhunk.snapenhance.manager.BuildConfig class SharedConfig( context: Context @@ -16,8 +17,10 @@ class SharedConfig( var snapchatPackageName get() = sharedPreferences.getString("snapchatPackageName", "com.snapchat.android")?.takeIf { it.isNotEmpty() } ?: "com.snapchat.android" set(value) = sharedPreferences.edit().putString("snapchatPackageName", value).apply() - var snapEnhancePackageName get() = sharedPreferences.getString("snapEnhancePackageName", "me.rhunk.snapenhance")?.takeIf { it.isNotEmpty() } ?: "me.rhunk.snapenhance" + var snapEnhancePackageName get() = sharedPreferences.getString("snapEnhancePackageName", BuildConfig.APPLICATION_ID)?.takeIf { it.isNotEmpty() } ?: BuildConfig.APPLICATION_ID set(value) = sharedPreferences.edit().putString("snapEnhancePackageName", value).apply() + var enableRepackage get() = sharedPreferences.getBoolean("enableRepackage", false) + set(value) = sharedPreferences.edit().putBoolean("enableRepackage", value).apply() var useRootInstaller get() = sharedPreferences.getBoolean("useRootInstaller", false) set(value) = sharedPreferences.edit().putBoolean("useRootInstaller", value).apply() 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 @@ -1,252 +0,0 @@ -package me.rhunk.snapenhance.manager.lspatch - -import android.content.Context -import com.android.tools.build.apkzlib.sign.SigningExtension -import com.android.tools.build.apkzlib.sign.SigningOptions -import com.android.tools.build.apkzlib.zip.AlignmentRules -import com.android.tools.build.apkzlib.zip.ZFile -import com.android.tools.build.apkzlib.zip.ZFileOptions -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.PROXY_APP_COMPONENT_FACTORY -import me.rhunk.snapenhance.manager.lspatch.config.PatchConfig -import me.rhunk.snapenhance.manager.lspatch.util.ApkSignatureHelper -import java.io.ByteArrayInputStream -import java.io.ByteArrayOutputStream -import java.io.File -import java.security.KeyStore -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 -) { - - 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(lspatchMetadata.first, lspatchMetadata.second)) - - return ByteArrayOutputStream().apply { - ManifestEditor(ByteArrayInputStream(data), this, property).processManifest() - flush() - close() - }.toByteArray() - } - - private fun provideSigningExtension(): SigningExtension { - val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()) - keyStore.load(context.assets.open("lspatch/keystore.jks"), "android".toCharArray()) - val key = keyStore.getEntry("androiddebugkey", KeyStore.PasswordProtection("android".toCharArray())) as KeyStore.PrivateKeyEntry - val certificates = key.certificateChain.mapNotNull { it as? X509Certificate }.toTypedArray() - - return SigningExtension( - SigningOptions.builder().apply { - setMinSdkVersion(28) - setV2SigningEnabled(true) - setCertificates(*certificates) - setKey(key.privateKey) - }.build() - ) - } - - private fun resignApk(inputApkFile: File, outputFile: File) { - printLog("Resigning ${inputApkFile.absolutePath} to ${outputFile.absolutePath}") - val dstZFile = ZFile.openReadWrite(outputFile, ZFileOptions()) - val inZFile = ZFile.openReadOnly(inputApkFile) - - inZFile.entries().forEach { entry -> - dstZFile.add(entry.centralDirectoryHeader.name, entry.open()) - } - - // sign apk - runCatching { - provideSigningExtension().register(dstZFile) - }.onFailure { - throw Exception("Failed to sign apk", it) - } - - dstZFile.realign() - dstZFile.close() - inZFile.close() - 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 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, - debuggable = false, - overrideVersionCode = false, - sigBypassLevel = 2, - originalSignature = ApkSignatureHelper.getApkSignInfo(inputApkFile.absolutePath), - appComponentFactory = "androidx.core.app.CoreComponentFactory" - ).let { Gson().toJson(it) } - - // sign apk - runCatching { - provideSigningExtension().register(dstZFile) - }.onFailure { - throw Exception("Failed to sign apk", it) - } - - 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(), (dexObfuscationConfig?.metadataManifestField ?: "lspatch") to Base64.encode(patchConfig.toByteArray())) - dstZFile.add("AndroidManifest.xml", patchedManifestData.inputStream()) - } - - //add config - printLog("Adding config") - dstZFile.add("assets/" + (dexObfuscationConfig?.configFilePath ?: "lspatch/config.json"), ByteArrayInputStream(patchConfig.toByteArray())) - - // add 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/${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) -> - 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 (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( - ".RSA" - )) - ) continue - 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") - } - - fun patchSplits(inputs: List<File>): Map<String, File> { - val outputs = mutableMapOf<String, File>() - inputs.forEach { input -> - val outputFile = File.createTempFile("patched", ".apk", context.externalCacheDir ?: context.cacheDir) - if (input.name.contains("split")) { - resignApk(input, outputFile) - outputs[input.name] = outputFile - return@forEach - } - patch(input, outputFile) - outputs["base.apk"] = outputFile - } - return outputs - } - - private fun patch(input: File, outputFile: File) { - //check if input apk is already patched - var isAlreadyPatched = false - var inputFile = input - - // extract origin - printLog("Extracting origin apk") - ZipFile(input).use { zipFile -> - zipFile.getEntry("assets/lspatch/origin.apk")?.apply { - inputFile = File.createTempFile("origin", ".apk") - inputFile.outputStream().use { - zipFile.getInputStream(this).copyTo(it) - } - isAlreadyPatched = true - } - } - - if (outputFile.exists()) outputFile.delete() - - printLog("Patching apk") - runCatching { - patchApk(inputFile, outputFile) - }.onFailure { - if (isAlreadyPatched) { - inputFile.delete() - } - outputFile.delete() - printLog("Failed to patch") - printLog(it) - } - } -}- \ No newline at end of file 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 @@ -1,107 +0,0 @@ -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 @@ -1,7 +0,0 @@ -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 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/lspatch/config/PatchConfig.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/lspatch/config/PatchConfig.kt @@ -1,19 +0,0 @@ -package me.rhunk.snapenhance.manager.lspatch.config - -data class PatchConfig( - val useManager: Boolean = false, - val debuggable: Boolean = false, - val overrideVersionCode: Boolean = false, - val sigBypassLevel: Int = 0, - val originalSignature: String? = null, - val appComponentFactory: String? = null, - val lspConfig: LSPConfig? = LSPConfig() -) { - data class LSPConfig( - var API_CODE: Int = 93, - var VERSION_CODE: Int = 360, - var VERSION_NAME: String = "0.5.1", - var CORE_VERSION_CODE: Int = 6649, - var CORE_VERSION_NAME: String = "1.8.5", - ) -}- \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/lspatch/util/ApkSignatureHelper.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/lspatch/util/ApkSignatureHelper.kt @@ -1,146 +0,0 @@ -package me.rhunk.snapenhance.manager.lspatch.util; - -import java.io.IOException -import java.io.RandomAccessFile -import java.io.UnsupportedEncodingException -import java.nio.ByteBuffer -import java.nio.ByteOrder -import java.security.cert.Certificate -import java.util.Enumeration -import java.util.jar.JarEntry -import java.util.jar.JarFile - - -//https://github.com/LSPosed/LSPatch/blob/master/patch/src/main/java/org/lsposed/patch/util/ApkSignatureHelper.java -object ApkSignatureHelper { - private val APK_V2_MAGIC = charArrayOf('A', 'P', 'K', ' ', 'S', 'i', 'g', ' ', - 'B', 'l', 'o', 'c', 'k', ' ', '4', '2') - - private fun toChars(mSignature: ByteArray): CharArray { - val N = mSignature.size - val N2 = N * 2 - val text = CharArray(N2) - for (j in 0 until N) { - val v = mSignature[j] - var d = v.toInt() shr 4 and 0xf - text[j * 2] = (if (d >= 10) 'a'.code + d - 10 else '0'.code + d).toChar() - d = v.toInt() and 0xf - text[j * 2 + 1] = (if (d >= 10) 'a'.code + d - 10 else '0'.code + d).toChar() - } - return text - } - - private fun loadCertificates( - jarFile: JarFile, - je: JarEntry?, - readBuffer: ByteArray - ): Array<Certificate?>? { - try { - val `is` = jarFile.getInputStream(je) - while (`is`.read(readBuffer, 0, readBuffer.size) != -1) { - } - `is`.close() - return je?.certificates as Array<Certificate?>? - } catch (e: Exception) { - } - return null - } - - fun getApkSignInfo(apkFilePath: String): String? { - return try { - getApkSignV2(apkFilePath) - } catch (e: Exception) { - getApkSignV1(apkFilePath) - } - } - - fun getApkSignV1(apkFilePath: String?): String? { - val readBuffer = ByteArray(8192) - var certs: Array<Certificate?>? = null - try { - val jarFile = JarFile(apkFilePath) - val entries: Enumeration<*> = jarFile.entries() - while (entries.hasMoreElements()) { - val je = entries.nextElement() as JarEntry - if (je.isDirectory) { - continue - } - if (je.name.startsWith("META-INF/")) { - continue - } - val localCerts = loadCertificates(jarFile, je, readBuffer) - if (certs == null) { - certs = localCerts - } else { - for (i in certs.indices) { - var found = false - for (j in localCerts!!.indices) { - if (certs[i] != null && certs[i] == localCerts[j]) { - found = true - break - } - } - if (!found || certs.size != localCerts.size) { - jarFile.close() - return null - } - } - } - } - jarFile.close() - return if (certs != null) String(toChars(certs[0]!!.encoded)) else null - } catch (ignored: Throwable) { - } - return null - } - - @Throws(IOException::class) - private fun getApkSignV2(apkFilePath: String): String { - RandomAccessFile(apkFilePath, "r").use { apk -> - val buffer = ByteBuffer.allocate(0x10) - buffer.order(ByteOrder.LITTLE_ENDIAN) - apk.seek(apk.length() - 0x6) - apk.readFully(buffer.array(), 0x0, 0x6) - val offset = buffer.getInt() - if (buffer.getShort().toInt() != 0) { - throw UnsupportedEncodingException("no zip") - } - apk.seek((offset - 0x10).toLong()) - apk.readFully(buffer.array(), 0x0, 0x10) - if (!buffer.array().contentEquals(APK_V2_MAGIC.map { it.code.toByte() }.toByteArray())) { - throw UnsupportedEncodingException("no apk v2") - } - - // Read and compare size fields - apk.seek((offset - 0x18).toLong()) - apk.readFully(buffer.array(), 0x0, 0x8) - buffer.rewind() - var size = buffer.getLong().toInt() - val block = ByteBuffer.allocate(size + 0x8) - block.order(ByteOrder.LITTLE_ENDIAN) - apk.seek((offset - block.capacity()).toLong()) - apk.readFully(block.array(), 0x0, block.capacity()) - if (size.toLong() != block.getLong()) { - throw UnsupportedEncodingException("no apk v2") - } - while (block.remaining() > 24) { - size = block.getLong().toInt() - if (block.getInt() == 0x7109871a) { - // signer-sequence length, signer length, signed data length - block.position(block.position() + 12) - size = block.getInt() // digests-sequence length - - // digests, certificates length - block.position(block.position() + size + 0x4) - size = block.getInt() // certificate length - break - } else { - block.position(block.position() + size - 0x4) - } - } - val certificate = ByteArray(size) - block[certificate] - return String(toChars(certificate)) - } - } -}- \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/LSPatch.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/LSPatch.kt @@ -0,0 +1,233 @@ +package me.rhunk.snapenhance.manager.patch + +import android.content.Context +import com.android.tools.build.apkzlib.zip.AlignmentRules +import com.android.tools.build.apkzlib.zip.ZFile +import com.android.tools.build.apkzlib.zip.ZFileOptions +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.patch.config.Constants.PROXY_APP_COMPONENT_FACTORY +import me.rhunk.snapenhance.manager.patch.config.PatchConfig +import me.rhunk.snapenhance.manager.patch.util.ApkSignatureHelper +import me.rhunk.snapenhance.manager.patch.util.ApkSignatureHelper.provideSigningExtension +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.File +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 +) { + + 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(lspatchMetadata.first, lspatchMetadata.second)) + + return ByteArrayOutputStream().apply { + ManifestEditor(ByteArrayInputStream(data), this, property).processManifest() + flush() + close() + }.toByteArray() + } + + private fun resignApk(inputApkFile: File, outputFile: File) { + printLog("Resigning ${inputApkFile.absolutePath} to ${outputFile.absolutePath}") + val dstZFile = ZFile.openReadWrite(outputFile, ZFileOptions()) + val inZFile = ZFile.openReadOnly(inputApkFile) + + inZFile.entries().forEach { entry -> + dstZFile.add(entry.centralDirectoryHeader.name, entry.open()) + } + + // sign apk + runCatching { + provideSigningExtension(context.assets.open("lspatch/keystore.jks")).register(dstZFile) + }.onFailure { + throw Exception("Failed to sign apk", it) + } + + dstZFile.realign() + dstZFile.close() + inZFile.close() + 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 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, + debuggable = false, + overrideVersionCode = false, + sigBypassLevel = 2, + originalSignature = ApkSignatureHelper.getApkSignInfo(inputApkFile.absolutePath), + appComponentFactory = "androidx.core.app.CoreComponentFactory" + ).let { Gson().toJson(it) } + + // sign apk + runCatching { + provideSigningExtension(context.assets.open("lspatch/keystore.jks")).register(dstZFile) + }.onFailure { + throw Exception("Failed to sign apk", it) + } + + 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(), (dexObfuscationConfig?.metadataManifestField ?: "lspatch") to Base64.encode(patchConfig.toByteArray())) + dstZFile.add("AndroidManifest.xml", patchedManifestData.inputStream()) + } + + //add config + printLog("Adding config") + dstZFile.add("assets/" + (dexObfuscationConfig?.configFilePath ?: "lspatch/config.json"), ByteArrayInputStream(patchConfig.toByteArray())) + + // add 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/${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) -> + 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 (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( + ".RSA" + )) + ) continue + 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") + } + + fun patchSplits(inputs: List<File>): Map<String, File> { + val outputs = mutableMapOf<String, File>() + inputs.forEach { input -> + val outputFile = File.createTempFile("patched", ".apk", context.externalCacheDir ?: context.cacheDir) + if (input.name.contains("split")) { + resignApk(input, outputFile) + outputs[input.name] = outputFile + return@forEach + } + patch(input, outputFile) + outputs["base.apk"] = outputFile + } + return outputs + } + + private fun patch(input: File, outputFile: File) { + //check if input apk is already patched + var isAlreadyPatched = false + var inputFile = input + + // extract origin + printLog("Extracting origin apk") + ZipFile(input).use { zipFile -> + zipFile.getEntry("assets/lspatch/origin.apk")?.apply { + inputFile = File.createTempFile("origin", ".apk") + inputFile.outputStream().use { + zipFile.getInputStream(this).copyTo(it) + } + isAlreadyPatched = true + } + } + + if (outputFile.exists()) outputFile.delete() + + printLog("Patching apk") + runCatching { + patchApk(inputFile, outputFile) + }.onFailure { + if (isAlreadyPatched) { + inputFile.delete() + } + outputFile.delete() + printLog("Failed to patch") + printLog(it) + } + } +}+ \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/LSPatchObfuscation.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/LSPatchObfuscation.kt @@ -0,0 +1,58 @@ +package me.rhunk.snapenhance.manager.patch + +import me.rhunk.snapenhance.manager.patch.util.obfuscateDexFile +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) } +) { + + fun obfuscateMetaLoader(inputStream: InputStream, config: DexObfuscationConfig): File { + return inputStream.obfuscateDexFile(cacheFolder, 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())) + } + + fun obfuscateLoader(inputStream: InputStream, config: DexObfuscationConfig): File { + return inputStream.obfuscateDexFile(cacheFolder, 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) + )) + } +}+ \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/Repackager.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/Repackager.kt @@ -0,0 +1,89 @@ +package me.rhunk.snapenhance.manager.patch + +import android.content.Context +import com.android.tools.build.apkzlib.zip.AlignmentRules +import com.android.tools.build.apkzlib.zip.ZFile +import com.android.tools.build.apkzlib.zip.ZFileOptions +import com.wind.meditor.core.ManifestEditor +import com.wind.meditor.property.AttributeItem +import com.wind.meditor.property.ModificationProperty +import me.rhunk.snapenhance.manager.BuildConfig +import me.rhunk.snapenhance.manager.patch.util.ApkSignatureHelper.provideSigningExtension +import me.rhunk.snapenhance.manager.patch.util.obfuscateDexFile +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.File + +class Repackager( + private val context: Context, + private val cacheFolder: File, + private val packageName: String, +) { + private fun patchManifest(data: ByteArray): ByteArray { + val property = ModificationProperty() + + property.addManifestAttribute(AttributeItem("package", packageName).apply { + type = 3 + namespace = null + }) + + return ByteArrayOutputStream().apply { + ManifestEditor(ByteArrayInputStream(data), this, property).processManifest() + flush() + close() + }.toByteArray() + } + + fun patch(apkFile: File): File { + val outputFile = File(cacheFolder, "patched-${apkFile.name}") + runCatching { + patch(apkFile, outputFile) + }.onFailure { + outputFile.delete() + throw it + } + return outputFile + } + + fun patch(apkFile: File, outputFile: File) { + val dstZFile = ZFile.openReadWrite(outputFile, ZFileOptions().setAlignmentRule( + AlignmentRules.compose(AlignmentRules.constantForSuffix(".so", 4096)) + )) + provideSigningExtension(context.assets.open("lspatch/keystore.jks")).register(dstZFile) + val srcZFile = ZFile.openReadOnly(apkFile) + val dexFiles = mutableListOf<File>() + + for (entry in srcZFile.entries()) { + val name = entry.centralDirectoryHeader.name + if (name.startsWith("AndroidManifest.xml")) { + dstZFile.add(name, ByteArrayInputStream( + patchManifest(entry.read()) + ), false) + continue + } + if (name.startsWith("classes") && name.endsWith(".dex")) { + println("obfuscating $name") + val inputStream = entry.open() ?: continue + val obfuscatedDexFile = inputStream.obfuscateDexFile(cacheFolder, { dexFile -> + dexFile.classes.firstOrNull { it.type == "Lme/rhunk/snapenhance/common/Constants;" } != null + }, mapOf( + BuildConfig.APPLICATION_ID to packageName + ))?.also { dexFiles.add(it) } + + if (obfuscatedDexFile == null) { + inputStream.close() + dstZFile.add(name, entry.open(), false) + continue + } + + dstZFile.add(name, obfuscatedDexFile.inputStream(), false) + continue + } + dstZFile.add(name, entry.open(), false) + } + dstZFile.realign() + dstZFile.close() + srcZFile.close() + dexFiles.forEach { it.delete() } + } +}+ \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/config/Constants.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/config/Constants.kt @@ -0,0 +1,7 @@ +package me.rhunk.snapenhance.manager.patch.config + +//https://github.com/LSPosed/LSPatch/blob/master/share/java/src/main/java/org/lsposed/lspatch/share/Constants.java +object Constants { + 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/patch/config/PatchConfig.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/config/PatchConfig.kt @@ -0,0 +1,19 @@ +package me.rhunk.snapenhance.manager.patch.config + +data class PatchConfig( + val useManager: Boolean = false, + val debuggable: Boolean = false, + val overrideVersionCode: Boolean = false, + val sigBypassLevel: Int = 0, + val originalSignature: String? = null, + val appComponentFactory: String? = null, + val lspConfig: LSPConfig? = LSPConfig() +) { + data class LSPConfig( + var API_CODE: Int = 93, + var VERSION_CODE: Int = 360, + var VERSION_NAME: String = "0.5.1", + var CORE_VERSION_CODE: Int = 6649, + var CORE_VERSION_NAME: String = "1.8.5", + ) +}+ \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/util/ApkSignatureHelper.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/util/ApkSignatureHelper.kt @@ -0,0 +1,167 @@ +package me.rhunk.snapenhance.manager.patch.util; + +import com.android.tools.build.apkzlib.sign.SigningExtension +import com.android.tools.build.apkzlib.sign.SigningOptions +import java.io.IOException +import java.io.InputStream +import java.io.RandomAccessFile +import java.io.UnsupportedEncodingException +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.security.KeyStore +import java.security.cert.Certificate +import java.security.cert.X509Certificate +import java.util.Enumeration +import java.util.jar.JarEntry +import java.util.jar.JarFile + + +//https://github.com/LSPosed/LSPatch/blob/master/patch/src/main/java/org/lsposed/patch/util/ApkSignatureHelper.java +object ApkSignatureHelper { + private val APK_V2_MAGIC = charArrayOf('A', 'P', 'K', ' ', 'S', 'i', 'g', ' ', + 'B', 'l', 'o', 'c', 'k', ' ', '4', '2') + + fun provideSigningExtension(keyStoreInputStream: InputStream): SigningExtension { + val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()) + keyStore.load(keyStoreInputStream, "android".toCharArray()) + val key = keyStore.getEntry("androiddebugkey", KeyStore.PasswordProtection("android".toCharArray())) as KeyStore.PrivateKeyEntry + val certificates = key.certificateChain.mapNotNull { it as? X509Certificate }.toTypedArray() + + return SigningExtension( + SigningOptions.builder().apply { + setMinSdkVersion(28) + setV2SigningEnabled(true) + setCertificates(*certificates) + setKey(key.privateKey) + }.build() + ) + } + + private fun toChars(mSignature: ByteArray): CharArray { + val N = mSignature.size + val N2 = N * 2 + val text = CharArray(N2) + for (j in 0 until N) { + val v = mSignature[j] + var d = v.toInt() shr 4 and 0xf + text[j * 2] = (if (d >= 10) 'a'.code + d - 10 else '0'.code + d).toChar() + d = v.toInt() and 0xf + text[j * 2 + 1] = (if (d >= 10) 'a'.code + d - 10 else '0'.code + d).toChar() + } + return text + } + + private fun loadCertificates( + jarFile: JarFile, + je: JarEntry?, + readBuffer: ByteArray + ): Array<Certificate?>? { + try { + val `is` = jarFile.getInputStream(je) + while (`is`.read(readBuffer, 0, readBuffer.size) != -1) { + } + `is`.close() + return je?.certificates as Array<Certificate?>? + } catch (e: Exception) { + } + return null + } + + fun getApkSignInfo(apkFilePath: String): String? { + return try { + getApkSignV2(apkFilePath) + } catch (e: Exception) { + getApkSignV1(apkFilePath) + } + } + + fun getApkSignV1(apkFilePath: String?): String? { + val readBuffer = ByteArray(8192) + var certs: Array<Certificate?>? = null + try { + val jarFile = JarFile(apkFilePath) + val entries: Enumeration<*> = jarFile.entries() + while (entries.hasMoreElements()) { + val je = entries.nextElement() as JarEntry + if (je.isDirectory) { + continue + } + if (je.name.startsWith("META-INF/")) { + continue + } + val localCerts = loadCertificates(jarFile, je, readBuffer) + if (certs == null) { + certs = localCerts + } else { + for (i in certs.indices) { + var found = false + for (j in localCerts!!.indices) { + if (certs[i] != null && certs[i] == localCerts[j]) { + found = true + break + } + } + if (!found || certs.size != localCerts.size) { + jarFile.close() + return null + } + } + } + } + jarFile.close() + return if (certs != null) String(toChars(certs[0]!!.encoded)) else null + } catch (ignored: Throwable) { + } + return null + } + + @Throws(IOException::class) + private fun getApkSignV2(apkFilePath: String): String { + RandomAccessFile(apkFilePath, "r").use { apk -> + val buffer = ByteBuffer.allocate(0x10) + buffer.order(ByteOrder.LITTLE_ENDIAN) + apk.seek(apk.length() - 0x6) + apk.readFully(buffer.array(), 0x0, 0x6) + val offset = buffer.getInt() + if (buffer.getShort().toInt() != 0) { + throw UnsupportedEncodingException("no zip") + } + apk.seek((offset - 0x10).toLong()) + apk.readFully(buffer.array(), 0x0, 0x10) + if (!buffer.array().contentEquals(APK_V2_MAGIC.map { it.code.toByte() }.toByteArray())) { + throw UnsupportedEncodingException("no apk v2") + } + + // Read and compare size fields + apk.seek((offset - 0x18).toLong()) + apk.readFully(buffer.array(), 0x0, 0x8) + buffer.rewind() + var size = buffer.getLong().toInt() + val block = ByteBuffer.allocate(size + 0x8) + block.order(ByteOrder.LITTLE_ENDIAN) + apk.seek((offset - block.capacity()).toLong()) + apk.readFully(block.array(), 0x0, block.capacity()) + if (size.toLong() != block.getLong()) { + throw UnsupportedEncodingException("no apk v2") + } + while (block.remaining() > 24) { + size = block.getLong().toInt() + if (block.getInt() == 0x7109871a) { + // signer-sequence length, signer length, signed data length + block.position(block.position() + 12) + size = block.getInt() // digests-sequence length + + // digests, certificates length + block.position(block.position() + size + 0x4) + size = block.getInt() // certificate length + break + } else { + block.position(block.position() + size - 0x4) + } + } + val certificate = ByteArray(size) + block[certificate] + return String(toChars(certificate)) + } + } +}+ \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/util/DexLibExt.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/util/DexLibExt.kt @@ -0,0 +1,63 @@ +package me.rhunk.snapenhance.manager.patch.util + +import org.jf.dexlib2.Opcodes +import org.jf.dexlib2.dexbacked.DexBackedDexFile +import org.jf.dexlib2.iface.DexFile +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 + + +private fun obfuscateStrings(dexFile: DexFile, dexStrings: Map<String, String?>): DexPool { + 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 + println("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) + } + return dexPool +} + +fun InputStream.obfuscateDexFile(cacheFolder: File, dexStrings: Map<String, String?>) + = this.obfuscateDexFile(cacheFolder, { true }, dexStrings)!! + +fun InputStream.obfuscateDexFile(cacheFolder: File, filter: (DexFile) -> Boolean, dexStrings: Map<String, String?>): File? { + val dexFile = DexBackedDexFile.fromInputStream(Opcodes.forApi(29), BufferedInputStream(this)) + if (!filter(dexFile)) return null + val outputFile = File.createTempFile("dexobf", ".dex", cacheFolder) + obfuscateStrings(dexFile, dexStrings).writeTo(FileDataStore(outputFile)) + return outputFile +} diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/MainActivity.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/MainActivity.kt @@ -16,10 +16,11 @@ import me.rhunk.snapenhance.manager.ui.tab.Tab import me.rhunk.snapenhance.manager.ui.tab.impl.HomeTab import me.rhunk.snapenhance.manager.ui.tab.impl.SettingsTab import me.rhunk.snapenhance.manager.ui.tab.impl.download.InstallPackageTab +import me.rhunk.snapenhance.manager.ui.tab.impl.download.RepackageTab class MainActivity : ComponentActivity() { companion object{ - private val primaryTabs = listOf(HomeTab::class, SettingsTab::class, InstallPackageTab::class) + private val primaryTabs = listOf(HomeTab::class, SettingsTab::class, InstallPackageTab::class, RepackageTab::class) } override fun onCreate(savedInstanceState: Bundle?) { diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/HomeTab.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/HomeTab.kt @@ -10,10 +10,10 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.OpenInNew import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.OpenInNew import androidx.compose.material3.Card import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -27,7 +27,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import me.rhunk.snapenhance.manager.lspatch.config.Constants +import me.rhunk.snapenhance.manager.patch.config.Constants import me.rhunk.snapenhance.manager.ui.tab.Tab import me.rhunk.snapenhance.manager.ui.tab.impl.download.SEDownloadTab import me.rhunk.snapenhance.manager.ui.tab.impl.download.SnapchatPatchTab @@ -66,6 +66,7 @@ class HomeTab : Tab("home", true, icon = Icons.Default.Home) { Text(text = "SnapEnhance", fontSize = 24.sp, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Bold) snapEnhanceInfo?.let { Text(text = "${it.versionName} (${it.longVersionCode}) - ${if ((it.applicationInfo.flags and FLAG_DEBUGGABLE) != 0) "Debug" else "Release"}", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(it.packageName, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) } } Row( 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 @@ -22,10 +22,11 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import me.rhunk.snapenhance.manager.ui.tab.Tab +import kotlin.random.Random class SettingsTab : Tab("settings", isPrimary = true, icon = Icons.Default.Settings) { @Composable - private fun ConfigEditRow(getValue: () -> String?, setValue: (String) -> Unit, label: String) { + private fun ConfigEditRow(getValue: () -> String?, setValue: (String) -> Unit, label: String, randomValueProvider: (() -> String)? = null) { var showDialog by remember { mutableStateOf(false) } if (showDialog) { @@ -65,6 +66,13 @@ class SettingsTab : Tab("settings", isPrimary = true, icon = Icons.Default.Setti }) { Text(text = "Cancel") } + if (randomValueProvider != null) { + Button(onClick = { + textFieldValue = TextFieldValue(randomValueProvider(), TextRange(0)) + }) { + Text(text = "Random") + } + } Button(onClick = { setValue(textFieldValue.text) showDialog = false @@ -131,14 +139,17 @@ class SettingsTab : Tab("settings", isPrimary = true, icon = Icons.Default.Setti Column { Spacer(modifier = Modifier.height(16.dp)) ConfigEditRow( - getValue = { sharedConfig.snapchatPackageName }, - setValue = { sharedConfig.snapchatPackageName = it }, - label = "Override Snapchat package name" - ) - ConfigEditRow( getValue = { sharedConfig.snapEnhancePackageName }, setValue = { sharedConfig.snapEnhancePackageName = it }, - label = "Override SnapEnhance package name" + label = "Override SnapEnhance package name", + randomValueProvider = { + (0..Random.nextInt(7, 16)).map { ('a'..'z').random() }.joinToString("").chunked(4).joinToString(".") + } + ) + ConfigBooleanRow( + getValue = { sharedConfig.enableRepackage }, + setValue = { sharedConfig.enableRepackage = it }, + label = "Repackage SnapEnhance (experimental)" ) ConfigBooleanRow( getValue = { sharedConfig.useRootInstaller }, 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 @@ -20,7 +20,7 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import me.rhunk.snapenhance.manager.data.APKMirror import me.rhunk.snapenhance.manager.data.DownloadItem -import me.rhunk.snapenhance.manager.lspatch.LSPatch +import me.rhunk.snapenhance.manager.patch.LSPatch import me.rhunk.snapenhance.manager.ui.components.DowngradeNoticeDialog import me.rhunk.snapenhance.manager.ui.tab.Tab import okio.use diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/RepackageTab.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/RepackageTab.kt @@ -0,0 +1,86 @@ +package me.rhunk.snapenhance.manager.ui.tab.impl.download + +import android.os.Bundle +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.rhunk.snapenhance.manager.patch.Repackager +import me.rhunk.snapenhance.manager.ui.tab.Tab +import java.io.File + +enum class RepackageState { + IDLE, + WORKING, + SUCCESS, + FAILED +} + +class RepackageTab : Tab("repackage") { + private var throwable: Throwable? = null + + private suspend fun repackage(apk: File, oldPackage: String, state: MutableState<RepackageState>) { + state.value = RepackageState.WORKING + val repackager = Repackager(activity, activity.externalCacheDirs.first(), sharedConfig.snapEnhancePackageName) + + runCatching { + repackager.patch(apk) + }.onFailure { + throwable = it + state.value = RepackageState.FAILED + return + }.onSuccess { originApk -> + state.value = RepackageState.SUCCESS + + withContext(Dispatchers.Main) { + navigation.navigateTo(InstallPackageTab::class, Bundle().apply { + putString("downloadPath", originApk.absolutePath) + putString("appPackage", oldPackage) + putBoolean("uninstall", true) + }, noHistory = true) + } + + return + } + } + + @Composable + override fun Content() { + val apkPath = remember { getArguments()?.getString("apkPath") } ?: return + val oldPackage = remember { getArguments()?.getString("oldPackage") } ?: return + val state = remember { mutableStateOf(RepackageState.IDLE) } + + LaunchedEffect(apkPath) { + launch(Dispatchers.IO) { + repackage(File(apkPath), oldPackage, state) + } + } + + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + CircularProgressIndicator() + when (state.value) { + RepackageState.WORKING -> Text(text = "Repackaging ...") + RepackageState.FAILED -> { + Text(text = "Failed") + Text(text = (throwable?.localizedMessage + throwable?.stackTraceToString())) + } + else -> {} + } + } + } +}+ \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/SEDownloadTab.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/SEDownloadTab.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.window.Dialog import com.google.gson.JsonParser import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import me.rhunk.snapenhance.manager.BuildConfig import me.rhunk.snapenhance.manager.data.download.SEArtifact import me.rhunk.snapenhance.manager.data.download.SEVersion import me.rhunk.snapenhance.manager.ui.components.DowngradeNoticeDialog @@ -82,7 +83,9 @@ class SEDownloadTab : Tab("se_download") { var selectedVersion by remember { mutableStateOf(null as SEVersion?) } var selectedArtifact by remember { mutableStateOf(null as SEArtifact?) } - val isAppInstalled = remember { runCatching { activity.packageManager.getPackageInfo(sharedConfig.snapEnhancePackageName, 0) != null }.getOrNull() != null } + val snapEnhanceApp = remember { + runCatching { activity.packageManager.getPackageInfo(BuildConfig.APPLICATION_ID, 0) }.getOrNull() + } var showDowngradeNotice by remember { mutableStateOf(false) } @@ -209,7 +212,21 @@ class SEDownloadTab : Tab("se_download") { .fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally ) { - if (isAppInstalled) { + if (snapEnhanceApp != null) { + if (sharedConfig.enableRepackage && sharedConfig.snapEnhancePackageName != snapEnhanceApp.packageName) { + Button( + onClick = { + navigation.navigateTo(RepackageTab::class, Bundle().apply { + putString("apkPath", snapEnhanceApp.applicationInfo.sourceDir) + putString("oldPackage", snapEnhanceApp.packageName) + }, noHistory = true) + }, + enabled = true, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = "Repackage installed version (>=2.0.0)") + } + } Button( onClick = { triggerPackageInstallation(true) @@ -222,7 +239,7 @@ class SEDownloadTab : Tab("se_download") { } Button( onClick = { - if (isAppInstalled) { + if (snapEnhanceApp != null) { showDowngradeNotice = true } else { triggerPackageInstallation(false) @@ -231,7 +248,7 @@ class SEDownloadTab : Tab("se_download") { enabled = selectedVersion != null && selectedArtifact != null, modifier = Modifier.fillMaxWidth() ) { - Text(text = if (isAppInstalled) "Update" else "Install") + Text(text = if (snapEnhanceApp != null) "Update" else "Install") } } } diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/SnapchatPatchTab.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/SnapchatPatchTab.kt @@ -23,7 +23,7 @@ import kotlinx.coroutines.withContext import me.rhunk.snapenhance.manager.R import me.rhunk.snapenhance.manager.data.APKMirror import me.rhunk.snapenhance.manager.data.DownloadItem -import me.rhunk.snapenhance.manager.lspatch.config.Constants +import me.rhunk.snapenhance.manager.patch.config.Constants import me.rhunk.snapenhance.manager.ui.components.ConfirmationDialog import me.rhunk.snapenhance.manager.ui.tab.Tab import java.io.File