commit a82c9d1738769f3ea69042ec59847c25d4a1d4fb parent 5ac93fee3d332ebb82792968c9a6e96bc8c36f5f Author: rhunk <101876869+rhunk@users.noreply.github.com> Date: Sat, 21 Oct 2023 21:44:47 +0200 feat: patch manager - LSPatch v0.5.1-390 Diffstat:
19 files changed, 581 insertions(+), 2 deletions(-)
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml @@ -1,5 +1,7 @@ [versions] agp = "8.1.2" +apksig = "8.0.2" +guava = "32.1.3-jre" kotlin = "1.9.0" kotlinx-coroutines-android = "1.7.3" @@ -33,6 +35,7 @@ androidx-material-ripple = { module = "androidx.compose.material:material-ripple androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation-compose" } androidx-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "ui-tooling-preview" } androidx-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "ui-tooling-preview" } +apksig = { module = "com.android.tools.build:apksig", version.ref = "apksig" } bcprov-jdk18on = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bcprov-jdk18on" } coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil-compose" } coil-video = { module = "io.coil-kt:coil-video", version.ref = "coil-compose" } @@ -40,6 +43,7 @@ coroutines = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-andro dexlib2 = { group = "org.smali", name = "dexlib2", version.ref = "dexlib2" } ffmpeg-kit = { group = "com.arthenica", name = "ffmpeg-kit-full-gpl", version.ref = "ffmpeg-kit" } gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } +guava = { module = "com.google.guava:guava", version.ref = "guava" } junit = { module = "junit:junit", version.ref = "junit" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } osmdroid-android = { group = "org.osmdroid", name = "osmdroid-android", version.ref = "osmdroid-android" } diff --git a/manager/.gitignore b/manager/.gitignore @@ -0,0 +1 @@ +/build+ \ No newline at end of file diff --git a/manager/build.gradle.kts b/manager/build.gradle.kts @@ -0,0 +1,83 @@ +import com.android.build.gradle.internal.api.BaseVariantOutputImpl + +plugins { + alias(libs.plugins.androidApplication) + alias(libs.plugins.kotlinAndroid) +} + +android { + namespace = rootProject.ext["applicationId"].toString() + ".manager" + compileSdk = 34 + + androidResources { + noCompress += ".so" + } + + buildFeatures { + compose = true + buildConfig = true + } + + defaultConfig { + buildConfigField("String", "APPLICATION_ID", "\"${rootProject.ext["applicationId"]}\"") + } + + composeOptions { + kotlinCompilerExtensionVersion = "1.5.2" + } + + defaultConfig { + applicationId = rootProject.ext["applicationId"].toString() + ".manager" + versionCode = 1 + versionName = "1.0.0" + minSdk = 28 + targetSdk = 34 + multiDexEnabled = true + } + + buildTypes { + release { + isMinifyEnabled = true + proguardFiles += file("proguard-rules.pro") + } + debug { + isMinifyEnabled = false + } + } + + applicationVariants.all { + outputs.map { it as BaseVariantOutputImpl }.forEach { outputVariant -> + outputVariant.outputFileName = "manager.apk" + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = "1.8" + } +} + +configurations { + all { + resolutionStrategy { + exclude(group = "com.google.guava", module = "listenablefuture") + } + } +} + +dependencies { + implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar")))) + implementation(libs.guava) + implementation(libs.apksig) + implementation(libs.gson) + implementation(libs.androidx.material3) + implementation(libs.androidx.activity.ktx) + implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.material.icons.core) + implementation(libs.androidx.material.ripple) + implementation(libs.androidx.material.icons.extended) +}+ \ No newline at end of file diff --git a/manager/libs/ManifestEditor-1.0.2.jar b/manager/libs/ManifestEditor-1.0.2.jar Binary files differ. diff --git a/manager/libs/apkzlib.jar b/manager/libs/apkzlib.jar Binary files differ. diff --git a/manager/proguard-rules.pro b/manager/proguard-rules.pro @@ -0,0 +1,2 @@ +-dontwarn com.google.errorprone.annotations.** +-dontwarn com.google.auto.value.**+ \ No newline at end of file diff --git a/manager/src/main/AndroidManifest.xml b/manager/src/main/AndroidManifest.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools"> + + <uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" + tools:ignore="QueryAllPackagesPermission" /> + <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> + <uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" /> + + <application + android:label="SE Manager" + android:icon="@android:drawable/ic_input_add"> + <activity android:name=".MainActivity" android:exported="true"> + <intent-filter> + <action android:name="android.intent.action.MAIN" /> + <category android:name="android.intent.category.LAUNCHER" /> + </intent-filter> + </activity> + </application> +</manifest>+ \ No newline at end of file diff --git a/manager/src/main/assets/lspatch/dexes/loader.dex b/manager/src/main/assets/lspatch/dexes/loader.dex Binary files differ. diff --git a/manager/src/main/assets/lspatch/dexes/metaloader.dex b/manager/src/main/assets/lspatch/dexes/metaloader.dex Binary files differ. diff --git a/manager/src/main/assets/lspatch/keystore.jks b/manager/src/main/assets/lspatch/keystore.jks Binary files differ. diff --git a/manager/src/main/assets/lspatch/so/arm64-v8a/liblspatch.so b/manager/src/main/assets/lspatch/so/arm64-v8a/liblspatch.so Binary files differ. diff --git a/manager/src/main/assets/lspatch/so/armeabi-v7a/liblspatch.so b/manager/src/main/assets/lspatch/so/armeabi-v7a/liblspatch.so Binary files differ. diff --git a/manager/src/main/assets/lspatch/version.txt b/manager/src/main/assets/lspatch/version.txt @@ -0,0 +1 @@ +0.5.1+ \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/MainActivity.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/MainActivity.kt @@ -0,0 +1,109 @@ +package me.rhunk.snapenhance.manager + +import android.os.Build +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.border +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import me.rhunk.snapenhance.manager.lspatch.LSPatch +import java.io.File +import java.io.PrintWriter +import java.io.StringWriter + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + val coroutineScope = rememberCoroutineScope() + MaterialTheme( + colorScheme = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (isSystemInDarkTheme()) dynamicDarkColorScheme(LocalContext.current) + else dynamicLightColorScheme(LocalContext.current) + } else MaterialTheme.colorScheme + ) { + val context = LocalContext.current + val logs = remember { mutableStateListOf<String>() } + fun printLog(data: Any) { + when (data) { + is Throwable -> { + logs += data.message.toString() + logs += StringWriter().apply { + data.printStackTrace(PrintWriter(this)) + }.toString() + } + else -> logs += data.toString() + } + } + + val scrollState = rememberLazyListState(0) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(20.dp), + modifier = Modifier.padding(10.dp) + ) { + Text(text = "SE Manager") + + Button(onClick = { + coroutineScope.launch(Dispatchers.IO) { + runCatching { + val lspatch = LSPatch( + context, + mapOf( + BuildConfig.APPLICATION_ID to File(context.packageManager.getPackageInfo(BuildConfig.APPLICATION_ID, 0).applicationInfo.sourceDir) + ) + ) { printLog(it) } + lspatch.patch( + File(context.packageManager.getPackageInfo("com.snapchat.android", 0).applicationInfo.sourceDir), + File(context.filesDir, "patched.apk") + ) + }.onFailure { printLog(it) } + } + }) { + Text(text = "Test patch apk") + } + + LazyColumn( + state = scrollState, + modifier = Modifier.fillMaxWidth().padding(5.dp).height(500.dp).border(1.dp, color = Color.Black), + content = { + items(logs) { + Text(text = it, modifier = Modifier.padding(2.dp)) + } + } + ) + + LaunchedEffect(logs.size) { + scrollState.scrollToItem((logs.size - 1).coerceAtLeast(0)) + } + } + } + } + } +}+ \ 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 @@ -0,0 +1,175 @@ +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.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 +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 + + +//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 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 { + val property = ModificationProperty() + + property.addApplicationAttribute(AttributeItem("appComponentFactory", PROXY_APP_COMPONENT_FACTORY)) + property.addMetaData(ModificationProperty.MetaData("lspatch", lspatchMetadata)) + + return ByteArrayOutputStream().apply { + ManifestEditor(ByteArrayInputStream(data), this, property).processManifest() + flush() + close() + }.toByteArray() + } + + @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 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 { + 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 + SigningExtension( + SigningOptions.builder().apply { + setMinSdkVersion(28) + setV2SigningEnabled(true) + setCertificates(*(key.certificateChain as Array<X509Certificate>)) + setKey(key.privateKey) + }.build() + ).apply { + register(dstZFile) + } + }.onFailure { + throw Exception("Failed to sign apk", it) + } + + printLog("Patching manifest") + + 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())) + dstZFile.add("AndroidManifest.xml", patchedManifestData.inputStream()) + } + + //add config + printLog("Adding config") + dstZFile.add("assets/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")) + + //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) + } + + + //embed modules + printLog("embedding modules") + modules.forEach { (packageName, module) -> + dstZFile.add("assets/lspatch/modules/$packageName.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 (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) + } + + dstZFile.realign() + dstZFile.close() + printLog("Done") + } + + 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) + }.onSuccess { + outputFile.delete() + } + } +}+ \ 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 @@ -0,0 +1,8 @@ +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/lspatch/config/PatchConfig.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/lspatch/config/PatchConfig.kt @@ -0,0 +1,19 @@ +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 @@ -0,0 +1,146 @@ +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/settings.gradle.kts b/settings.gradle.kts @@ -21,4 +21,5 @@ include(":common") include(":core") include(":app") include(":mapper") -include(":native")- \ No newline at end of file +include(":native") +include(":manager")+ \ No newline at end of file