feat: patch manager

- LSPatch v0.5.1-390
This commit is contained in:
rhunk
2023-10-21 21:44:47 +02:00
parent 5ac93fee3d
commit a82c9d1738
19 changed files with 570 additions and 1 deletions

View File

@@ -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" }

1
manager/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

83
manager/build.gradle.kts Normal file
View File

@@ -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)
}

Binary file not shown.

BIN
manager/libs/apkzlib.jar Normal file

Binary file not shown.

2
manager/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,2 @@
-dontwarn com.google.errorprone.annotations.**
-dontwarn com.google.auto.value.**

View File

@@ -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>

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1 @@
0.5.1

View File

@@ -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))
}
}
}
}
}
}

View File

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

View File

@@ -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"
}

View File

@@ -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",
)
}

View File

@@ -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))
}
}
}

View File

@@ -21,4 +21,5 @@ include(":common")
include(":core")
include(":app")
include(":mapper")
include(":native")
include(":native")
include(":manager")